Unverified 提交 a6425c26 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

Queue Supabase edge function deploys (#3289)

## Summary - Add a project-scoped Supabase deploy queue with concurrency capped at 8. - Run deploy-all through bounded concurrency instead of unbounded parallel requests. - Stream Supabase deploy progress as a dyad-status indicator, with an E2E covering queue progress. ## Test plan - npm run fmt && npm run lint:fix && npm run ts - npm test -- src/__tests__/supabase_utils.test.ts src/__tests__/local_agent_handler.test.ts - npm test - PYTHON=/usr/local/bin/python3.13 npm run build - npm run e2e e2e-tests/local_agent_supabase_deploy_progress.spec.ts <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3289" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 98de516b
...@@ -22,6 +22,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi ...@@ -22,6 +22,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi
| [rules/openai-reasoning-models.md](rules/openai-reasoning-models.md) | Working with OpenAI reasoning model (o1/o3/o4-mini) conversation history | | [rules/openai-reasoning-models.md](rules/openai-reasoning-models.md) | Working with OpenAI reasoning model (o1/o3/o4-mini) conversation history |
| [rules/adding-settings.md](rules/adding-settings.md) | Adding a new user-facing setting or toggle to the Settings page | | [rules/adding-settings.md](rules/adding-settings.md) | Adding a new user-facing setting or toggle to the Settings page |
| [rules/chat-message-indicators.md](rules/chat-message-indicators.md) | Using `<dyad-status>` tags in chat messages for system indicators | | [rules/chat-message-indicators.md](rules/chat-message-indicators.md) | Using `<dyad-status>` tags in chat messages for system indicators |
| [rules/supabase-functions.md](rules/supabase-functions.md) | Deploying, bundling, or queueing Supabase Edge Functions |
| [rules/product-principles.md](rules/product-principles.md) | Planning new features, especially via `dyad:swarm-to-plan`, to guide design trade-offs | | [rules/product-principles.md](rules/product-principles.md) | Planning new features, especially via `dyad:swarm-to-plan`, to guide design trade-offs |
| [rules/jotai-testing.md](rules/jotai-testing.md) | Unit-testing Jotai atoms/hooks with `renderHook`, especially across unmount/remount | | [rules/jotai-testing.md](rules/jotai-testing.md) | Unit-testing Jotai atoms/hooks with `renderHook`, especially across unmount/remount |
......
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
const functionWrites = Array.from({ length: 20 }, (_, index) => {
const functionName = `queue-test-${String(index + 1).padStart(2, "0")}`;
return {
name: "write_file",
args: {
path: `supabase/functions/${functionName}/index.ts`,
content: `import { corsHeaders } from "../_shared/cors.ts";
Deno.serve(() => {
return new Response(JSON.stringify({ functionName: "${functionName}" }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});
`,
description: `Create ${functionName} edge function`,
},
};
});
export const fixture: LocalAgentFixture = {
description: "Create shared Supabase code and many edge functions",
turns: [
{
text: "I'll create shared Supabase code and several edge functions.",
toolCalls: [
{
name: "write_file",
args: {
path: "supabase/functions/_shared/cors.ts",
content: `export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
`,
description: "Create shared CORS helper",
},
},
...functionWrites,
],
},
{
text: "Done. The shared helper and edge functions have been created.",
},
],
};
import { expect } from "@playwright/test";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
testSkipIfWindows(
"local-agent - shows Supabase deploy queue progress",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.appManagement.getTitleBarAppNameButton().click();
await po.appManagement.clickConnectSupabaseButton();
await po.navigation.clickBackButton();
await po.chatActions.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/supabase-deploy-progress", {
skipWaitForCompletion: true,
});
await expect(async () => {
await expect(
po.page
.getByText(
/Deploying Supabase functions: \d+\/20 complete \(\d+ active, \d+ queued\)/,
)
.or(po.page.getByText("Supabase functions deployed: 20/20 complete"))
.first(),
).toBeVisible();
}).toPass({ timeout: Timeout.LONG });
await po.chatActions.waitForChatCompletion();
await expect(
po.page.getByText("Supabase functions deployed: 20/20 complete"),
).toBeVisible({ timeout: Timeout.LONG });
},
);
...@@ -9,3 +9,5 @@ Content here ...@@ -9,3 +9,5 @@ Content here
``` ```
Valid states: `"finished"`, `"in-progress"`, `"aborted"` Valid states: `"finished"`, `"in-progress"`, `"aborted"`
- Renderer unit tests that import `DyadMarkdownParser` should mock `../preview_panel/FileEditor`; otherwise the `DyadWrite` import initializes Monaco and Happy DOM may try to fetch `cdn.jsdelivr.net`, causing offline `ENOTFOUND` failures.
...@@ -103,6 +103,8 @@ For app features that fetch `api.dyad.sh` directly, add a test-only env override ...@@ -103,6 +103,8 @@ For app features that fetch `api.dyad.sh` directly, add a test-only env override
Packaged Electron E2E runs may fail inside the Codex sandbox before any test logic executes, with Playwright reporting `electron.launch: Process failed to launch!` and the Electron process exiting with `SIGABRT`. Packaged Electron E2E runs may fail inside the Codex sandbox before any test logic executes, with Playwright reporting `electron.launch: Process failed to launch!` and the Electron process exiting with `SIGABRT`.
The same sandbox issue can appear earlier as a Playwright `config.webServer` startup failure, for example `Error: listen EPERM: operation not permitted 0.0.0.0:3500` from the fake LLM server. Re-run the same E2E command outside the sandbox before treating it as a product regression.
If this happens: If this happens:
1. Verify whether the failure reproduces on an existing known-good E2E spec. 1. Verify whether the failure reproduces on an existing known-good E2E spec.
...@@ -156,6 +158,8 @@ This pattern provides a more reliable signal that the async operation has comple ...@@ -156,6 +158,8 @@ This pattern provides a more reliable signal that the async operation has comple
2. It confirms the operation finished (loading state disappeared) 2. It confirms the operation finished (loading state disappeared)
3. It avoids race conditions where the button might briefly be in the DOM but not yet updated 3. It avoids race conditions where the button might briefly be in the DOM but not yet updated
For streamed progress indicators that may complete quickly, allow the assertion to match either the transient in-progress text or the final completed text, then assert the final state after the operation completes.
## E2E test fixtures with .dyad directories ## E2E test fixtures with .dyad directories
When adding E2E test fixtures that need a `.dyad` directory for testing: When adding E2E test fixtures that need a `.dyad` directory for testing:
......
...@@ -14,6 +14,7 @@ Agent tool definitions live in `src/pro/main/ipc/handlers/local_agent/tools/`. E ...@@ -14,6 +14,7 @@ Agent tool definitions live in `src/pro/main/ipc/handlers/local_agent/tools/`. E
## User-visible tool output ## User-visible tool output
- For Local Agent post-tool side effects that happen after the model/tool loop (for example shared Supabase function redeploys), use `ctx.onXmlComplete(...)` with escaped `<dyad-output>` content to surface warnings/errors inline. `warningMessages` creates toast warnings, and throwing turns the whole stream into a `ChatErrorBox`. - For Local Agent post-tool side effects that happen after the model/tool loop (for example shared Supabase function redeploys), use `ctx.onXmlComplete(...)` with escaped `<dyad-output>` content to surface warnings/errors inline. `warningMessages` creates toast warnings, and throwing turns the whole stream into a `ChatErrorBox`.
- **`ctx.onXmlComplete` only updates the message `content` column and the UI; it does NOT make output visible to future agent turns.** `parseAiMessagesJson` reads from `aiMessagesJson` whenever it's present and ignores `content` entirely. For post-loop output that the agent should see next turn (deploy results, step-limit notices), also push a trailing assistant message into `accumulatedAiMessages` BEFORE the `aiMessagesJson` write, e.g.: `accumulatedAiMessages.push({ role: "assistant", content: [{ type: "text", text: xml }] })`.
## Stream retries ## Stream retries
......
# Supabase Functions
- Supabase Edge Function deploy queueing is per project. `bundleOnly=true` bundling can run with high concurrency, but `bundleOnly=false` activating deploys must run exclusively for the same project and should wait for same-project bundle jobs already in flight.
...@@ -473,6 +473,61 @@ describe("handleLocalAgentStream", () => { ...@@ -473,6 +473,61 @@ describe("handleLocalAgentStream", () => {
}); });
}); });
it("persists successful shared-module Supabase deploy status into aiMessagesJson", async () => {
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat({
supabaseProjectId: "supabase-project-id",
});
mockStreamResult = createFakeStream([{ type: "text-delta", text: "ok" }]);
vi.mocked(deployAllFunctionsIfNeeded).mockImplementationOnce(
async (ctx) => {
ctx.onXmlComplete(
'<dyad-status title="Supabase functions deployed: 2/2 complete" state="finished">\n2 succeeded\n0 failed\n</dyad-status>',
);
return { success: true };
},
);
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{
placeholderMessageId: 10,
systemPrompt: "You are helpful",
dyadRequestId,
},
);
const contentUpdates = dbOperations.updates.filter(
(u) => u.data.content !== undefined,
);
const finalContent = contentUpdates[contentUpdates.length - 1].data
.content as string;
expect(finalContent).toContain("<dyad-status");
expect(finalContent).toContain(
'title="Supabase functions deployed: 2/2 complete"',
);
expect(commitAllChanges).toHaveBeenCalled();
const aiMessagesUpdates = dbOperations.updates.filter(
(u) => u.data.aiMessagesJson !== undefined,
);
expect(aiMessagesUpdates.length).toBeGreaterThan(0);
const persistedAiMessages = JSON.stringify(
(
aiMessagesUpdates[aiMessagesUpdates.length - 1].data
.aiMessagesJson as { messages: unknown[] }
).messages,
);
expect(persistedAiMessages).toContain("<dyad-status");
expect(persistedAiMessages).toContain(
'title=\\"Supabase functions deployed: 2/2 complete\\"',
);
});
it("appends shared-module Supabase deploy warnings as dyad-output", async () => { it("appends shared-module Supabase deploy warnings as dyad-output", async () => {
const { event } = createFakeEvent(); const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true }); mockSettings = buildTestSettings({ enableDyadPro: true });
...@@ -511,6 +566,22 @@ describe("handleLocalAgentStream", () => { ...@@ -511,6 +566,22 @@ describe("handleLocalAgentStream", () => {
"Some Supabase functions failed to deploy: Failed to bundle get-user-role: Rate limited (429): Too Many Requests", "Some Supabase functions failed to deploy: Failed to bundle get-user-role: Rate limited (429): Too Many Requests",
); );
expect(commitAllChanges).toHaveBeenCalled(); expect(commitAllChanges).toHaveBeenCalled();
// Persist deploy XML into aiMessagesJson so future agent turns can see it.
const aiMessagesUpdates = dbOperations.updates.filter(
(u) => u.data.aiMessagesJson !== undefined,
);
expect(aiMessagesUpdates.length).toBeGreaterThan(0);
const persistedAiMessages = JSON.stringify(
(
aiMessagesUpdates[aiMessagesUpdates.length - 1].data
.aiMessagesJson as { messages: unknown[] }
).messages,
);
expect(persistedAiMessages).toContain('<dyad-output type=\\"warning\\"');
expect(persistedAiMessages).toContain(
'message=\\"Supabase function deploy warning\\"',
);
}); });
it("appends shared-module Supabase deploy failures as dyad-output and still commits", async () => { it("appends shared-module Supabase deploy failures as dyad-output and still commits", async () => {
...@@ -554,6 +625,22 @@ describe("handleLocalAgentStream", () => { ...@@ -554,6 +625,22 @@ describe("handleLocalAgentStream", () => {
"Failed to redeploy Supabase functions: RateLimitError: Rate limited (429): Too Many Requests", "Failed to redeploy Supabase functions: RateLimitError: Rate limited (429): Too Many Requests",
); );
expect(commitAllChanges).toHaveBeenCalled(); expect(commitAllChanges).toHaveBeenCalled();
// Persist deploy XML into aiMessagesJson so future agent turns can see it.
const aiMessagesUpdates = dbOperations.updates.filter(
(u) => u.data.aiMessagesJson !== undefined,
);
expect(aiMessagesUpdates.length).toBeGreaterThan(0);
const persistedAiMessages = JSON.stringify(
(
aiMessagesUpdates[aiMessagesUpdates.length - 1].data
.aiMessagesJson as { messages: unknown[] }
).messages,
);
expect(persistedAiMessages).toContain('<dyad-output type=\\"error\\"');
expect(persistedAiMessages).toContain(
'message=\\"Failed to deploy Supabase functions\\"',
);
}); });
}); });
......
...@@ -262,7 +262,7 @@ describe("retryWithRateLimit", () => { ...@@ -262,7 +262,7 @@ describe("retryWithRateLimit", () => {
expect(operation).toHaveBeenCalledTimes(3); // 1 initial + 2 retries expect(operation).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
}); });
it("should throw after default max retries (6)", async () => { it("should throw after default max retries (10)", async () => {
const rateLimitError = { response: { status: 429 } }; const rateLimitError = { response: { status: 429 } };
const operation = vi.fn().mockRejectedValue(rateLimitError); const operation = vi.fn().mockRejectedValue(rateLimitError);
...@@ -277,7 +277,7 @@ describe("retryWithRateLimit", () => { ...@@ -277,7 +277,7 @@ describe("retryWithRateLimit", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
await expectation; await expectation;
expect(operation).toHaveBeenCalledTimes(9); // 1 initial + 8 retries expect(operation).toHaveBeenCalledTimes(11); // 1 initial + 10 retries
}); });
}); });
......
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
deployAllSupabaseFunctions,
type SupabaseDeployProgress,
} from "@/supabase_admin/supabase_utils";
import {
bulkUpdateFunctions,
deploySupabaseFunction,
listSupabaseFunctions,
} from "@/supabase_admin/supabase_management_client";
vi.mock("@/supabase_admin/supabase_management_client", async () => {
const actual = await vi.importActual<
typeof import("@/supabase_admin/supabase_management_client")
>("@/supabase_admin/supabase_management_client");
return {
...actual,
bulkUpdateFunctions: vi.fn(),
deploySupabaseFunction: vi.fn(),
listSupabaseFunctions: vi.fn(),
};
});
async function waitForAssertion(assertion: () => void) {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < 1000) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
throw lastError;
}
describe("deployAllSupabaseFunctions progress", () => {
let appPath: string;
beforeEach(async () => {
appPath = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-supabase-"));
for (const functionName of ["alpha", "beta"]) {
await fs.mkdir(
path.join(appPath, "supabase", "functions", functionName),
{
recursive: true,
},
);
await fs.writeFile(
path.join(appPath, "supabase", "functions", functionName, "index.ts"),
"Deno.serve(() => new Response('ok'));",
);
}
vi.mocked(deploySupabaseFunction).mockImplementation(
async ({ functionName }) =>
({
slug: functionName,
}) as any,
);
vi.mocked(listSupabaseFunctions).mockResolvedValue([]);
});
afterEach(async () => {
vi.resetAllMocks();
await fs.rm(appPath, { recursive: true, force: true });
});
it("emits finished only after bulk activation completes", async () => {
const progressEvents: SupabaseDeployProgress[] = [];
let finishActivation: () => void = () => {};
vi.mocked(bulkUpdateFunctions).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishActivation = resolve;
}),
);
const deployment = deployAllSupabaseFunctions({
appPath,
supabaseProjectId: "project-id",
supabaseOrganizationSlug: null,
skipPruneEdgeFunctions: true,
onProgress: (progress) => progressEvents.push(progress),
});
await waitForAssertion(() => {
expect(bulkUpdateFunctions).toHaveBeenCalledOnce();
});
expect(progressEvents.map((event) => event.phase)).not.toContain(
"finished",
);
finishActivation();
await expect(deployment).resolves.toEqual([]);
expect(progressEvents.at(-1)?.phase).toBe("finished");
});
it("emits failed instead of finished when bulk activation fails", async () => {
const progressEvents: SupabaseDeployProgress[] = [];
vi.mocked(bulkUpdateFunctions).mockRejectedValue(
new Error("activation down"),
);
await expect(
deployAllSupabaseFunctions({
appPath,
supabaseProjectId: "project-id",
supabaseOrganizationSlug: null,
skipPruneEdgeFunctions: true,
onProgress: (progress) => progressEvents.push(progress),
}),
).resolves.toEqual(["Failed to bulk update functions: activation down"]);
expect(progressEvents.map((event) => event.phase)).not.toContain(
"finished",
);
expect(progressEvents.at(-1)?.phase).toBe("failed");
});
});
import { afterEach, describe, expect, it, vi } from "vitest";
import { bulkUpdateFunctions } from "@/supabase_admin/supabase_management_client";
import {
enqueueSupabaseDeploy,
resetSupabaseDeployQueuesForTests,
} from "@/supabase_admin/supabase_deploy_queue";
vi.mock("../main/settings", () => ({
readSettings: vi.fn(() => ({
supabase: {
accessToken: { value: "test-token" },
expiresIn: 60 * 60,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
})),
writeSettings: vi.fn(),
}));
async function waitForAssertion(assertion: () => void) {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < 1000) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
throw lastError;
}
describe("bulkUpdateFunctions deploy queueing", () => {
afterEach(() => {
resetSupabaseDeployQueuesForTests();
vi.unstubAllGlobals();
});
it("queues bulk activations behind active bundle jobs and ahead of later bundles for the same project", async () => {
resetSupabaseDeployQueuesForTests();
let releaseBundle: () => void = () => {};
const activeBundle = enqueueSupabaseDeploy("project-1", true, async () => {
await new Promise<void>((resolve) => {
releaseBundle = resolve;
});
});
let resolveBulkUpdate: (response: Response) => void = () => {};
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveBulkUpdate = resolve;
}),
);
vi.stubGlobal("fetch", fetchMock);
let bulkUpdateResolved = false;
const bulkUpdate = bulkUpdateFunctions({
supabaseProjectId: "project-1",
functions: [],
organizationSlug: null,
}).then(() => {
bulkUpdateResolved = true;
});
let laterBundleStarted = false;
const laterBundle = enqueueSupabaseDeploy("project-1", true, async () => {
laterBundleStarted = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchMock).not.toHaveBeenCalled();
expect(bulkUpdateResolved).toBe(false);
expect(laterBundleStarted).toBe(false);
releaseBundle();
await activeBundle;
await waitForAssertion(() => {
expect(fetchMock).toHaveBeenCalledOnce();
});
expect(bulkUpdateResolved).toBe(false);
expect(laterBundleStarted).toBe(false);
resolveBulkUpdate(new Response("", { status: 200 }));
await bulkUpdate;
await laterBundle;
expect(bulkUpdateResolved).toBe(true);
expect(laterBundleStarted).toBe(true);
});
});
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
isServerFunction, isServerFunction,
isSharedServerModule, isSharedServerModule,
extractFunctionNameFromPath, extractFunctionNameFromPath,
mapSettledWithConcurrency,
} from "@/supabase_admin/supabase_utils"; } from "@/supabase_admin/supabase_utils";
import { import {
toPosixPath, toPosixPath,
...@@ -10,6 +11,12 @@ import { ...@@ -10,6 +11,12 @@ import {
buildSignature, buildSignature,
type FileStatEntry, type FileStatEntry,
} from "@/supabase_admin/supabase_management_client"; } from "@/supabase_admin/supabase_management_client";
import {
enqueueSupabaseDeploy,
resetSupabaseDeployQueuesForTests,
SUPABASE_ACTIVATING_DEPLOY_CONCURRENCY,
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY,
} from "@/supabase_admin/supabase_deploy_queue";
describe("isServerFunction", () => { describe("isServerFunction", () => {
describe("returns true for valid function paths", () => { describe("returns true for valid function paths", () => {
...@@ -350,3 +357,227 @@ describe("buildSignature", () => { ...@@ -350,3 +357,227 @@ describe("buildSignature", () => {
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2)); expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
}); });
}); });
describe("mapSettledWithConcurrency", () => {
it("limits active tasks and preserves input order", async () => {
let activeCount = 0;
let maxActiveCount = 0;
const results = await mapSettledWithConcurrency(
[1, 2, 3, 4, 5],
2,
async (value) => {
activeCount++;
maxActiveCount = Math.max(maxActiveCount, activeCount);
await new Promise((resolve) => setTimeout(resolve, 0));
activeCount--;
if (value === 3) {
throw new Error("boom");
}
return value * 10;
},
);
expect(maxActiveCount).toBeLessThanOrEqual(2);
expect(results).toEqual([
{ status: "fulfilled", value: 10 },
{ status: "fulfilled", value: 20 },
{
status: "rejected",
reason: expect.objectContaining({ message: "boom" }),
},
{ status: "fulfilled", value: 40 },
{ status: "fulfilled", value: 50 },
]);
});
});
describe("enqueueSupabaseDeploy", () => {
it("limits active bundle-only deploys per project", async () => {
resetSupabaseDeployQueuesForTests();
let activeCount = 0;
let maxActiveCount = 0;
const startedIndexes: number[] = [];
const releaseTasks: Array<() => void> = [];
const tasks = Array.from(
{ length: SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY + 4 },
(_, index) =>
enqueueSupabaseDeploy("project-1", true, async () => {
startedIndexes.push(index);
activeCount++;
maxActiveCount = Math.max(maxActiveCount, activeCount);
await new Promise<void>((resolve) => {
releaseTasks[index] = resolve;
});
activeCount--;
return index;
}),
);
expect(startedIndexes).toHaveLength(
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY,
);
expect(maxActiveCount).toBe(SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY);
for (const releaseTask of releaseTasks.slice(
0,
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY,
)) {
releaseTask();
}
await new Promise((resolve) => setTimeout(resolve, 0));
expect(startedIndexes).toHaveLength(
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY + 4,
);
for (const releaseTask of releaseTasks.slice(
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY,
)) {
releaseTask();
}
await expect(Promise.all(tasks)).resolves.toEqual(
Array.from(
{ length: SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY + 4 },
(_, index) => index,
),
);
});
it("runs activating deploys exclusively for a project", async () => {
resetSupabaseDeployQueuesForTests();
const startedIndexes: number[] = [];
const releaseTasks: Array<() => void> = [];
const tasks = Array.from(
{ length: SUPABASE_ACTIVATING_DEPLOY_CONCURRENCY + 2 },
(_, index) =>
enqueueSupabaseDeploy("project-1", false, async () => {
startedIndexes.push(index);
await new Promise<void>((resolve) => {
releaseTasks[index] = resolve;
});
return index;
}),
);
expect(startedIndexes).toEqual([0]);
releaseTasks[0]();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(startedIndexes).toEqual([0, 1]);
releaseTasks[1]();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(startedIndexes).toEqual([0, 1, 2]);
releaseTasks[2]();
await expect(Promise.all(tasks)).resolves.toEqual([0, 1, 2]);
});
it("does not start same-project bundle-only deploys while an activating deploy is queued", async () => {
resetSupabaseDeployQueuesForTests();
const startedTasks: string[] = [];
const releaseTasks: Record<string, () => void> = {};
const bundleTasks = Array.from(
{ length: SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY },
(_, index) =>
enqueueSupabaseDeploy("project-1", true, async () => {
const taskName = `bundle-${index}`;
startedTasks.push(taskName);
await new Promise<void>((resolve) => {
releaseTasks[taskName] = resolve;
});
return taskName;
}),
);
const activatingTask = enqueueSupabaseDeploy(
"project-1",
false,
async () => {
startedTasks.push("activate");
await new Promise<void>((resolve) => {
releaseTasks.activate = resolve;
});
return "activate";
},
);
const extraBundleTask = enqueueSupabaseDeploy(
"project-1",
true,
async () => {
startedTasks.push("extra-bundle");
return "extra-bundle";
},
);
expect(startedTasks).toEqual(
Array.from(
{ length: SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY },
(_, index) => `bundle-${index}`,
),
);
for (
let index = 0;
index < SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY;
index++
) {
releaseTasks[`bundle-${index}`]();
}
await new Promise((resolve) => setTimeout(resolve, 0));
expect(startedTasks).toContain("activate");
expect(startedTasks).not.toContain("extra-bundle");
releaseTasks.activate();
await expect(
Promise.all([...bundleTasks, activatingTask, extraBundleTask]),
).resolves.toEqual([
...Array.from(
{ length: SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY },
(_, index) => `bundle-${index}`,
),
"activate",
"extra-bundle",
]);
});
it("allows activating deploys for different projects to run concurrently", async () => {
resetSupabaseDeployQueuesForTests();
let activeCount = 0;
let maxActiveCount = 0;
const releaseTasks: Array<() => void> = [];
const tasks = ["project-1", "project-2"].map((projectId, index) =>
enqueueSupabaseDeploy(projectId, false, async () => {
activeCount++;
maxActiveCount = Math.max(maxActiveCount, activeCount);
await new Promise<void>((resolve) => {
releaseTasks[index] = resolve;
});
activeCount--;
return projectId;
}),
);
expect(maxActiveCount).toBe(2);
releaseTasks[0]();
releaseTasks[1]();
await expect(Promise.all(tasks)).resolves.toEqual([
"project-1",
"project-2",
]);
});
});
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DyadMarkdownParser } from "./DyadMarkdownParser";
vi.mock("../preview_panel/FileEditor", () => ({
FileEditor: () => null,
}));
describe("DyadMarkdownParser dyad-status", () => {
afterEach(() => {
cleanup();
});
it("honors explicit aborted state on closed status tags", () => {
render(
<DyadMarkdownParser
content={
'<dyad-status title="Supabase functions failed" state="aborted">\n0 succeeded\n1 failed\n</dyad-status>'
}
/>,
);
const statusCard = screen.getByRole("button");
expect(screen.getByText("Supabase functions failed")).toBeTruthy();
expect(statusCard.className).toContain("border-l-red-500");
});
});
...@@ -350,10 +350,18 @@ function parseCustomTags(content: string): ContentPiece[] { ...@@ -350,10 +350,18 @@ function parseCustomTags(content: string): ContentPiece[] {
function getState({ function getState({
isStreaming, isStreaming,
inProgress, inProgress,
explicitState,
}: { }: {
isStreaming?: boolean; isStreaming?: boolean;
inProgress?: boolean; inProgress?: boolean;
explicitState?: string;
}): CustomTagState { }): CustomTagState {
if (explicitState === "aborted" || explicitState === "finished") {
return explicitState;
}
if (explicitState === "in-progress" || explicitState === "pending") {
return "pending";
}
if (!inProgress) { if (!inProgress) {
return "finished"; return "finished";
} }
...@@ -822,7 +830,11 @@ function renderCustomTag( ...@@ -822,7 +830,11 @@ function renderCustomTag(
node={{ node={{
properties: { properties: {
title: attributes.title || "Processing...", title: attributes.title || "Processing...",
state: getState({ isStreaming, inProgress }), state: getState({
isStreaming,
inProgress,
explicitState: attributes.state,
}),
}, },
}} }}
> >
......
...@@ -60,7 +60,7 @@ export function isRateLimitError(error: any): boolean { ...@@ -60,7 +60,7 @@ export function isRateLimitError(error: any): boolean {
// Retry configuration // Retry configuration
const RETRY_CONFIG = { const RETRY_CONFIG = {
maxRetries: 8, maxRetries: 10,
baseDelay: 2_000, // 2 seconds baseDelay: 2_000, // 2 seconds
maxDelay: 30_000, // 30 seconds maxDelay: 30_000, // 30 seconds
jitterFactor: 0.1, // 10% jitter jitterFactor: 0.1, // 10% jitter
......
...@@ -1322,13 +1322,21 @@ export async function handleLocalAgentStream( ...@@ -1322,13 +1322,21 @@ export async function handleLocalAgentStream(
return false; // Cancelled - don't consume quota return false; // Cancelled - don't consume quota
} }
// Collect XML produced by post-turn side-effects (step-limit notice,
// Supabase deploy results) so we can persist them into aiMessagesJson.
// parseAiMessagesJson reads from aiMessagesJson when present and ignores
// the message's `content` column, so anything appended only to fullResponse
// would be invisible to subsequent agent turns.
const postTurnXmlParts: string[] = [];
// Check if we hit the step limit and append a notice to the response // Check if we hit the step limit and append a notice to the response
if (totalStepsExecuted >= maxToolCallSteps) { if (totalStepsExecuted >= maxToolCallSteps) {
logger.info( logger.info(
`Chat ${req.chatId} hit step limit of ${maxToolCallSteps} steps`, `Chat ${req.chatId} hit step limit of ${maxToolCallSteps} steps`,
); );
const stepLimitMessage = `\n\n<dyad-step-limit steps="${totalStepsExecuted}" limit="${maxToolCallSteps}">Automatically paused after ${totalStepsExecuted} tool calls.</dyad-step-limit>`; const stepLimitXml = `<dyad-step-limit steps="${totalStepsExecuted}" limit="${maxToolCallSteps}">Automatically paused after ${totalStepsExecuted} tool calls.</dyad-step-limit>`;
fullResponse += stepLimitMessage; postTurnXmlParts.push(stepLimitXml);
fullResponse += `\n\n${stepLimitXml}`;
await updateResponseInDb(placeholderMessageId, fullResponse); await updateResponseInDb(placeholderMessageId, fullResponse);
sendResponseChunk( sendResponseChunk(
event, event,
...@@ -1340,6 +1348,39 @@ export async function handleLocalAgentStream( ...@@ -1340,6 +1348,39 @@ export async function handleLocalAgentStream(
); );
} }
// In read-only and plan mode, skip the deploy step (commit follows below)
if (!readOnly && !planModeOnly) {
// Deploy all Supabase functions if shared modules changed
const deployResult = await deployAllFunctionsIfNeeded({
...ctx,
onXmlComplete: (finalXml) => {
postTurnXmlParts.push(finalXml);
ctx.onXmlComplete(finalXml);
},
});
if (deployResult.warning) {
const warningXml = `<dyad-output type="warning" message="${escapeXmlAttr("Supabase function deploy warning")}">${escapeXmlContent(deployResult.warning)}</dyad-output>`;
postTurnXmlParts.push(warningXml);
ctx.onXmlComplete(warningXml);
}
if (!deployResult.success) {
const errorXml = `<dyad-output type="error" message="${escapeXmlAttr("Failed to deploy Supabase functions")}">${escapeXmlContent(deployResult.error ?? "Unknown deploy error")}</dyad-output>`;
postTurnXmlParts.push(errorXml);
ctx.onXmlComplete(errorXml);
}
}
// Persist post-turn side-effects as a trailing assistant message so future
// agent turns can see them via aiMessagesJson. Done before the
// aiMessagesJson write below so deploy/step-limit info is captured even if
// a later step (e.g. commit) throws.
if (postTurnXmlParts.length > 0) {
accumulatedAiMessages.push({
role: "assistant",
content: [{ type: "text", text: postTurnXmlParts.join("\n") }],
});
}
// Save the AI SDK messages for multi-turn tool call preservation // Save the AI SDK messages for multi-turn tool call preservation
try { try {
const aiMessagesJson = getAiMessagesJsonIfWithinLimit( const aiMessagesJson = getAiMessagesJsonIfWithinLimit(
...@@ -1355,21 +1396,8 @@ export async function handleLocalAgentStream( ...@@ -1355,21 +1396,8 @@ export async function handleLocalAgentStream(
logger.warn("Failed to save AI messages JSON:", err); logger.warn("Failed to save AI messages JSON:", err);
} }
// In read-only and plan mode, skip deploys and commits // In read-only and plan mode, skip commits
if (!readOnly && !planModeOnly) { if (!readOnly && !planModeOnly) {
// Deploy all Supabase functions if shared modules changed
const deployResult = await deployAllFunctionsIfNeeded(ctx);
if (deployResult.warning) {
ctx.onXmlComplete(
`<dyad-output type="warning" message="${escapeXmlAttr("Supabase function deploy warning")}">${escapeXmlContent(deployResult.warning)}</dyad-output>`,
);
}
if (!deployResult.success) {
ctx.onXmlComplete(
`<dyad-output type="error" message="${escapeXmlAttr("Failed to deploy Supabase functions")}">${escapeXmlContent(deployResult.error ?? "Unknown deploy error")}</dyad-output>`,
);
}
// Commit all changes // Commit all changes
const commitResult = await commitAllChanges(ctx, ctx.chatSummary); const commitResult = await commitAllChanges(ctx, ctx.chatSummary);
......
...@@ -8,9 +8,16 @@ import { ...@@ -8,9 +8,16 @@ import {
gitAddAll, gitAddAll,
getGitUncommittedFiles, getGitUncommittedFiles,
} from "@/ipc/utils/git_utils"; } from "@/ipc/utils/git_utils";
import { deployAllSupabaseFunctions } from "../../../../../../supabase_admin/supabase_utils"; import {
deployAllSupabaseFunctions,
type SupabaseDeployProgress,
} from "../../../../../../supabase_admin/supabase_utils";
import { readSettings } from "../../../../../../main/settings"; import { readSettings } from "../../../../../../main/settings";
import type { AgentContext } from "../tools/types"; import {
escapeXmlAttr,
escapeXmlContent,
type AgentContext,
} from "../tools/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("file_operations"); const logger = log.scope("file_operations");
...@@ -21,6 +28,34 @@ export interface FileOperationResult { ...@@ -21,6 +28,34 @@ export interface FileOperationResult {
warning?: string; warning?: string;
} }
function renderSupabaseDeployStatus(progress: SupabaseDeployProgress): string {
const isComplete =
progress.phase === "finished" || progress.phase === "failed";
const title =
progress.phase === "finished"
? `Supabase functions deployed: ${progress.completed}/${progress.total} complete`
: progress.phase === "failed"
? `Supabase functions failed to deploy: ${progress.completed}/${progress.total} complete`
: `Deploying Supabase functions: ${progress.completed}/${progress.total} complete (${progress.active} active, ${progress.queued} queued)`;
const state =
progress.phase === "failed"
? "aborted"
: progress.phase === "finished"
? "finished"
: "in-progress";
const content = [
`${progress.succeeded} succeeded`,
`${progress.failed} failed`,
`${progress.active} active`,
`${progress.queued} queued`,
];
if (progress.functionName) {
content.push(`Latest: ${progress.functionName}`);
}
return `<dyad-status title="${escapeXmlAttr(title)}" state="${state}">\n${escapeXmlContent(content.join("\n"))}${isComplete ? "\n</dyad-status>" : ""}`;
}
/** /**
* Deploy all Supabase functions (after shared module changes) * Deploy all Supabase functions (after shared module changes)
*/ */
...@@ -31,6 +66,8 @@ export async function deployAllFunctionsIfNeeded( ...@@ -31,6 +66,8 @@ export async function deployAllFunctionsIfNeeded(
| "supabaseProjectId" | "supabaseProjectId"
| "supabaseOrganizationSlug" | "supabaseOrganizationSlug"
| "isSharedModulesChanged" | "isSharedModulesChanged"
| "onXmlStream"
| "onXmlComplete"
>, >,
): Promise<FileOperationResult> { ): Promise<FileOperationResult> {
if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) { if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) {
...@@ -45,6 +82,14 @@ export async function deployAllFunctionsIfNeeded( ...@@ -45,6 +82,14 @@ export async function deployAllFunctionsIfNeeded(
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug ?? null, supabaseOrganizationSlug: ctx.supabaseOrganizationSlug ?? null,
skipPruneEdgeFunctions: settings.skipPruneEdgeFunctions ?? false, skipPruneEdgeFunctions: settings.skipPruneEdgeFunctions ?? false,
onProgress: (progress) => {
const statusXml = renderSupabaseDeployStatus(progress);
if (progress.phase === "finished" || progress.phase === "failed") {
ctx.onXmlComplete(statusXml);
} else {
ctx.onXmlStream(statusXml);
}
},
}); });
if (deployErrors.length > 0) { if (deployErrors.length > 0) {
......
export const SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY = 8;
export const SUPABASE_ACTIVATING_DEPLOY_CONCURRENCY = 1;
type QueueTask<T> = {
operation: () => Promise<T>;
bundleOnly: boolean;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
class SupabaseDeployQueue {
private activeBundleOnlyCount = 0;
private activeActivatingCount = 0;
private readonly pendingTasks: QueueTask<unknown>[] = [];
enqueue<T>(bundleOnly: boolean, operation: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.pendingTasks.push({
operation,
bundleOnly,
resolve: resolve as (value: unknown) => void,
reject,
});
this.drain();
});
}
private drain() {
while (this.pendingTasks.length > 0) {
const task = this.pendingTasks[0];
if (!this.canStart(task)) {
return;
}
this.pendingTasks.shift();
this.incrementActiveCount(task);
void this.runTask(task);
}
}
private canStart(task: QueueTask<unknown>) {
if (task.bundleOnly) {
return (
this.activeActivatingCount === 0 &&
this.activeBundleOnlyCount < SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY
);
}
return (
this.activeActivatingCount < SUPABASE_ACTIVATING_DEPLOY_CONCURRENCY &&
this.activeBundleOnlyCount === 0
);
}
private incrementActiveCount(task: QueueTask<unknown>) {
if (task.bundleOnly) {
this.activeBundleOnlyCount++;
} else {
this.activeActivatingCount++;
}
}
private decrementActiveCount(task: QueueTask<unknown>) {
if (task.bundleOnly) {
this.activeBundleOnlyCount--;
} else {
this.activeActivatingCount--;
}
}
private async runTask(task: QueueTask<unknown>) {
try {
task.resolve(await task.operation());
} catch (error) {
task.reject(error);
} finally {
this.decrementActiveCount(task);
this.drain();
}
}
}
const deployQueuesByProject = new Map<string, SupabaseDeployQueue>();
export function enqueueSupabaseDeploy<T>(
supabaseProjectId: string,
bundleOnly: boolean,
operation: () => Promise<T>,
): Promise<T> {
let queue = deployQueuesByProject.get(supabaseProjectId);
if (!queue) {
queue = new SupabaseDeployQueue();
deployQueuesByProject.set(supabaseProjectId, queue);
}
return queue.enqueue(bundleOnly, operation);
}
export function resetSupabaseDeployQueuesForTests() {
deployQueuesByProject.clear();
}
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
retryWithRateLimit, retryWithRateLimit,
} from "../ipc/utils/retryWithRateLimit"; } from "../ipc/utils/retryWithRateLimit";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { enqueueSupabaseDeploy } from "./supabase_deploy_queue";
const fsPromises = fs.promises; const fsPromises = fs.promises;
...@@ -759,6 +760,30 @@ export async function deploySupabaseFunction({ ...@@ -759,6 +760,30 @@ export async function deploySupabaseFunction({
appPath: string; appPath: string;
bundleOnly?: boolean; bundleOnly?: boolean;
organizationSlug: string | null; organizationSlug: string | null;
}): Promise<DeployedFunctionResponse> {
return enqueueSupabaseDeploy(supabaseProjectId, bundleOnly, () =>
deploySupabaseFunctionUnqueued({
supabaseProjectId,
functionName,
appPath,
bundleOnly,
organizationSlug,
}),
);
}
async function deploySupabaseFunctionUnqueued({
supabaseProjectId,
functionName,
appPath,
bundleOnly = false,
organizationSlug,
}: {
supabaseProjectId: string;
functionName: string;
appPath: string;
bundleOnly?: boolean;
organizationSlug: string | null;
}): Promise<DeployedFunctionResponse> { }): Promise<DeployedFunctionResponse> {
logger.info( logger.info(
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`, `Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`,
...@@ -799,6 +824,19 @@ export async function deploySupabaseFunction({ ...@@ -799,6 +824,19 @@ export async function deploySupabaseFunction({
date: new Date(), date: new Date(),
}); });
if (IS_TEST_BUILD) {
await new Promise((resolve) => setTimeout(resolve, 100));
return {
id: `fake-${functionName}`,
slug: functionName,
name: functionName,
status: "ACTIVE",
version: 1,
entrypoint_path: entrypointPath,
import_map_path: importMapRelPath,
};
}
// 5) Prepare multipart form-data // 5) Prepare multipart form-data
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
function buildFormData() { function buildFormData() {
...@@ -864,11 +902,36 @@ export async function bulkUpdateFunctions({ ...@@ -864,11 +902,36 @@ export async function bulkUpdateFunctions({
supabaseProjectId: string; supabaseProjectId: string;
functions: DeployedFunctionResponse[]; functions: DeployedFunctionResponse[];
organizationSlug: string | null; organizationSlug: string | null;
}): Promise<void> {
return enqueueSupabaseDeploy(supabaseProjectId, false, () =>
bulkUpdateFunctionsUnqueued({
supabaseProjectId,
functions,
organizationSlug,
}),
);
}
async function bulkUpdateFunctionsUnqueued({
supabaseProjectId,
functions,
organizationSlug,
}: {
supabaseProjectId: string;
functions: DeployedFunctionResponse[];
organizationSlug: string | null;
}): Promise<void> { }): Promise<void> {
logger.info( logger.info(
`Bulk updating ${functions.length} functions for project: ${supabaseProjectId}`, `Bulk updating ${functions.length} functions for project: ${supabaseProjectId}`,
); );
if (IS_TEST_BUILD) {
logger.info(
`Skipped bulk updating ${functions.length} functions for project: ${supabaseProjectId} during test build`,
);
return;
}
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const response = await fetchWithRetry( const response = await fetchWithRetry(
......
...@@ -8,10 +8,55 @@ import { ...@@ -8,10 +8,55 @@ import {
listSupabaseFunctions, listSupabaseFunctions,
type DeployedFunctionResponse, type DeployedFunctionResponse,
} from "./supabase_management_client"; } from "./supabase_management_client";
import { SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY } from "./supabase_deploy_queue";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("supabase_utils"); const logger = log.scope("supabase_utils");
export interface SupabaseDeployProgress {
phase: "deploying" | "finished" | "failed";
total: number;
active: number;
queued: number;
completed: number;
succeeded: number;
failed: number;
functionName?: string;
}
export async function mapSettledWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>,
): Promise<PromiseSettledResult<R>[]> {
const results: Array<PromiseSettledResult<R> | undefined> = Array.from({
length: items.length,
});
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const currentIndex = nextIndex++;
try {
results[currentIndex] = {
status: "fulfilled",
value: await mapper(items[currentIndex], currentIndex),
};
} catch (reason) {
results[currentIndex] = {
status: "rejected",
reason,
};
}
}
}
const workerCount = Math.min(Math.max(1, concurrency), items.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results.map((result) => result!);
}
/** /**
* Extracts function name from Supabase edge function log event_message * Extracts function name from Supabase edge function log event_message
* Example: "[todo-activity] fetched 0 recent todos\n" -> "todo-activity" * Example: "[todo-activity] fetched 0 recent todos\n" -> "todo-activity"
...@@ -90,11 +135,13 @@ export async function deployAllSupabaseFunctions({ ...@@ -90,11 +135,13 @@ export async function deployAllSupabaseFunctions({
supabaseProjectId, supabaseProjectId,
supabaseOrganizationSlug, supabaseOrganizationSlug,
skipPruneEdgeFunctions, skipPruneEdgeFunctions,
onProgress,
}: { }: {
appPath: string; appPath: string;
supabaseProjectId: string; supabaseProjectId: string;
supabaseOrganizationSlug: string | null; supabaseOrganizationSlug: string | null;
skipPruneEdgeFunctions: boolean; skipPruneEdgeFunctions: boolean;
onProgress?: (progress: SupabaseDeployProgress) => void;
}): Promise<string[]> { }): Promise<string[]> {
const functionsDir = path.join(appPath, "supabase", "functions"); const functionsDir = path.join(appPath, "supabase", "functions");
...@@ -142,22 +189,61 @@ export async function deployAllSupabaseFunctions({ ...@@ -142,22 +189,61 @@ export async function deployAllSupabaseFunctions({
return []; return [];
} }
// Deploy all functions in parallel with bundleOnly=true logger.info(
logger.info(`Bundling ${validFunctions.length} functions in parallel...`); `Bundling ${validFunctions.length} functions with concurrency ${SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY}...`,
);
const totalFunctions = validFunctions.length;
let activeFunctions = 0;
let completedFunctions = 0;
let succeededFunctions = 0;
let failedFunctions = 0;
function emitProgress(
phase: SupabaseDeployProgress["phase"],
functionName?: string,
) {
onProgress?.({
phase,
total: totalFunctions,
active: activeFunctions,
queued: totalFunctions - activeFunctions - completedFunctions,
completed: completedFunctions,
succeeded: succeededFunctions,
failed: failedFunctions,
functionName,
});
}
emitProgress("deploying");
const deployResults = await Promise.allSettled( const deployResults = await mapSettledWithConcurrency(
validFunctions.map(async (functionName) => { validFunctions,
SUPABASE_BUNDLE_ONLY_DEPLOY_CONCURRENCY,
async (functionName) => {
activeFunctions++;
emitProgress("deploying", functionName);
logger.info(`Bundling function: ${functionName}`); logger.info(`Bundling function: ${functionName}`);
const result = await deploySupabaseFunction({ try {
supabaseProjectId, const result = await deploySupabaseFunction({
organizationSlug: supabaseOrganizationSlug, supabaseProjectId,
functionName, organizationSlug: supabaseOrganizationSlug,
appPath, functionName,
bundleOnly: true, appPath,
}); bundleOnly: true,
logger.info(`Successfully bundled function: ${functionName}`); });
return result; succeededFunctions++;
}), logger.info(`Successfully bundled function: ${functionName}`);
return result;
} catch (error) {
failedFunctions++;
throw error;
} finally {
activeFunctions--;
completedFunctions++;
emitProgress("deploying", functionName);
}
},
); );
// Collect successful results and errors // Collect successful results and errors
...@@ -175,6 +261,8 @@ export async function deployAllSupabaseFunctions({ ...@@ -175,6 +261,8 @@ export async function deployAllSupabaseFunctions({
} }
} }
const activationSucceeded = successfulDeploys.length > 0;
// Bulk update all successfully bundled functions to activate them // Bulk update all successfully bundled functions to activate them
if (successfulDeploys.length > 0) { if (successfulDeploys.length > 0) {
logger.info( logger.info(
...@@ -238,6 +326,10 @@ export async function deployAllSupabaseFunctions({ ...@@ -238,6 +326,10 @@ export async function deployAllSupabaseFunctions({
errors.push(errorMessage); errors.push(errorMessage);
} }
} }
emitProgress(
errors.length === 0 && activationSucceeded ? "finished" : "failed",
);
} catch (error: any) { } catch (error: any) {
const errorMessage = `Error reading functions directory: ${error.message}`; const errorMessage = `Error reading functions directory: ${error.message}`;
logger.error(errorMessage, error); logger.error(errorMessage, error);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论