@agent-native/dispatch 0.8.6 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/list_apps.js +1 -1
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/open_app.d.ts.map +1 -1
- package/dist/actions/open_app.js +7 -4
- package/dist/actions/open_app.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +2 -1
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/routes/pages/metrics.d.ts.map +1 -1
- package/dist/routes/pages/metrics.js +3 -1
- package/dist/routes/pages/metrics.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +104 -10
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +160 -4
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +1 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +1 -0
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/list_apps.ts +1 -1
- package/src/actions/open_app.ts +11 -4
- package/src/components/workspace-app-card.tsx +3 -2
- package/src/routes/pages/metrics.tsx +4 -1
- package/src/server/lib/app-creation-store.spec.ts +240 -0
- package/src/server/lib/app-creation-store.ts +130 -11
- package/src/server/lib/mcp-gateway.spec.ts +295 -0
- package/src/server/lib/mcp-gateway.ts +187 -4
- package/src/server/lib/usage-metrics-store.ts +2 -0
|
@@ -53,6 +53,7 @@ export function WorkspaceAppCard({
|
|
|
53
53
|
const href = workspaceAppHref(app);
|
|
54
54
|
const openInNewTab = isPendingBuilderHref(app);
|
|
55
55
|
const isPending = app.status === "pending";
|
|
56
|
+
const pendingLabel = app.statusLabel || "Builder branch";
|
|
56
57
|
const isArchived = !!app.archived;
|
|
57
58
|
const audience = app.audience ?? "internal";
|
|
58
59
|
const [editOpen, setEditOpen] = useState(false);
|
|
@@ -145,7 +146,7 @@ export function WorkspaceAppCard({
|
|
|
145
146
|
className="shrink-0 gap-1 border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
|
146
147
|
>
|
|
147
148
|
<IconClockHour4 size={12} />
|
|
148
|
-
|
|
149
|
+
{pendingLabel}
|
|
149
150
|
</Badge>
|
|
150
151
|
) : null}
|
|
151
152
|
{isArchived ? (
|
|
@@ -166,7 +167,7 @@ export function WorkspaceAppCard({
|
|
|
166
167
|
</p>
|
|
167
168
|
{isPending && app.branchName ? (
|
|
168
169
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
|
169
|
-
|
|
170
|
+
Builder branch: {app.branchName}
|
|
170
171
|
</p>
|
|
171
172
|
) : null}
|
|
172
173
|
{app.description ? (
|
|
@@ -47,6 +47,7 @@ interface AppAccessMetric {
|
|
|
47
47
|
name: string;
|
|
48
48
|
path: string;
|
|
49
49
|
status?: "ready" | "pending";
|
|
50
|
+
statusLabel?: string;
|
|
50
51
|
isDispatch: boolean;
|
|
51
52
|
accessLabel: string;
|
|
52
53
|
accessUsers: number;
|
|
@@ -440,7 +441,9 @@ function AppAccessTable({
|
|
|
440
441
|
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
441
442
|
)}
|
|
442
443
|
>
|
|
443
|
-
{row.status === "pending"
|
|
444
|
+
{row.status === "pending"
|
|
445
|
+
? row.statusLabel || "Builder branch"
|
|
446
|
+
: row.accessLabel}
|
|
444
447
|
</Badge>
|
|
445
448
|
</td>
|
|
446
449
|
<td className="px-2 py-3 text-right tabular-nums">
|
|
@@ -3,17 +3,104 @@ import { runWithRequestContext } from "@agent-native/core/server";
|
|
|
3
3
|
import {
|
|
4
4
|
generateWorkspaceAppDescription,
|
|
5
5
|
listWorkspaceApps,
|
|
6
|
+
updateWorkspaceAppMetadata,
|
|
6
7
|
} from "./app-creation-store.js";
|
|
7
8
|
|
|
8
9
|
const originalFetch = globalThis.fetch;
|
|
10
|
+
const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => {
|
|
13
|
+
const settings = new Map<string, unknown>();
|
|
14
|
+
const state = {
|
|
15
|
+
orgRole: "admin" as string | null,
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
settings,
|
|
19
|
+
state,
|
|
20
|
+
getSetting: vi.fn(async (key: string) => settings.get(key) ?? null),
|
|
21
|
+
putSetting: vi.fn(async (key: string, value: unknown) => {
|
|
22
|
+
settings.set(key, value);
|
|
23
|
+
}),
|
|
24
|
+
getDbExec: vi.fn(() => ({
|
|
25
|
+
execute: vi.fn(async () => ({
|
|
26
|
+
rows: state.orgRole ? [{ role: state.orgRole }] : [],
|
|
27
|
+
})),
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock("@agent-native/core/db", async (importOriginal) => {
|
|
33
|
+
const actual = await importOriginal<typeof import("@agent-native/core/db")>();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
getDbExec: () => mocks.getDbExec(),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock("@agent-native/core/settings", () => ({
|
|
41
|
+
getSetting: (...args: any[]) => mocks.getSetting(...args),
|
|
42
|
+
putSetting: (...args: any[]) => mocks.putSetting(...args),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("./dispatch-store.js", async (importOriginal) => {
|
|
46
|
+
const actual = await importOriginal<typeof import("./dispatch-store.js")>();
|
|
47
|
+
return {
|
|
48
|
+
...actual,
|
|
49
|
+
recordAudit: vi.fn(async () => {}),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
9
52
|
|
|
10
53
|
afterEach(() => {
|
|
11
54
|
vi.unstubAllEnvs();
|
|
12
55
|
vi.unstubAllGlobals();
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
mocks.settings.clear();
|
|
58
|
+
mocks.state.orgRole = "admin";
|
|
13
59
|
globalThis.fetch = originalFetch;
|
|
14
60
|
});
|
|
15
61
|
|
|
16
62
|
describe("listWorkspaceApps", () => {
|
|
63
|
+
function stubNoPendingContext() {
|
|
64
|
+
for (const key of [
|
|
65
|
+
"BRANCH",
|
|
66
|
+
"HEAD",
|
|
67
|
+
"VERCEL_GIT_COMMIT_REF",
|
|
68
|
+
"CF_PAGES_BRANCH",
|
|
69
|
+
"RENDER_GIT_BRANCH",
|
|
70
|
+
"FLY_BRANCH",
|
|
71
|
+
"WORKSPACE_GATEWAY_URL",
|
|
72
|
+
"DEPLOY_PRIME_URL",
|
|
73
|
+
"DEPLOY_URL",
|
|
74
|
+
"URL",
|
|
75
|
+
"APP_URL",
|
|
76
|
+
"BETTER_AUTH_URL",
|
|
77
|
+
]) {
|
|
78
|
+
vi.stubEnv(key, "");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stubManifest(
|
|
83
|
+
apps = [{ id: "dispatch", name: "Dispatch", path: "/dispatch" }],
|
|
84
|
+
) {
|
|
85
|
+
vi.stubEnv("AGENT_NATIVE_WORKSPACE_APPS_JSON", JSON.stringify(apps));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pendingApp(id: string, overrides: Record<string, unknown> = {}) {
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
name: id.charAt(0).toUpperCase() + id.slice(1),
|
|
92
|
+
description: `${id} is being created`,
|
|
93
|
+
path: `/${id}`,
|
|
94
|
+
builderUrl: `https://builder.io/app/projects/project-123/branch/${id}`,
|
|
95
|
+
branchName: id,
|
|
96
|
+
projectId: "project-123",
|
|
97
|
+
createdAt: "2026-05-20T18:00:00.000Z",
|
|
98
|
+
updatedAt: "2026-05-20T18:00:00.000Z",
|
|
99
|
+
expiresAt: "2999-01-01T00:00:00.000Z",
|
|
100
|
+
...overrides,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
17
104
|
it("prefers the live workspace gateway manifest when available", async () => {
|
|
18
105
|
const fetchMock = vi.fn(async () => {
|
|
19
106
|
return new Response(
|
|
@@ -69,6 +156,7 @@ describe("listWorkspaceApps", () => {
|
|
|
69
156
|
});
|
|
70
157
|
|
|
71
158
|
it("filters workspace apps by audience", async () => {
|
|
159
|
+
stubNoPendingContext();
|
|
72
160
|
vi.stubEnv(
|
|
73
161
|
"AGENT_NATIVE_WORKSPACE_APPS_JSON",
|
|
74
162
|
JSON.stringify([
|
|
@@ -99,6 +187,158 @@ describe("listWorkspaceApps", () => {
|
|
|
99
187
|
expect(apps.map((app) => app.id)).toEqual(["portal"]);
|
|
100
188
|
});
|
|
101
189
|
|
|
190
|
+
it("shows current branch and legacy pending Builder app rows", async () => {
|
|
191
|
+
stubManifest();
|
|
192
|
+
vi.stubEnv("BRANCH", "feature-a");
|
|
193
|
+
mocks.settings.set(settingsKey, {
|
|
194
|
+
pendingApps: [
|
|
195
|
+
pendingApp("mail", {
|
|
196
|
+
builderUrl: "https://builder.io/app/projects/project-123/branch/old",
|
|
197
|
+
}),
|
|
198
|
+
pendingApp("mail", {
|
|
199
|
+
contextId: "branch:feature-a",
|
|
200
|
+
contextLabel: "Branch: feature-a",
|
|
201
|
+
builderUrl:
|
|
202
|
+
"https://builder.io/app/projects/project-123/branch/feature-a",
|
|
203
|
+
}),
|
|
204
|
+
pendingApp("calendar", {
|
|
205
|
+
contextId: "branch:feature-b",
|
|
206
|
+
contextLabel: "Branch: feature-b",
|
|
207
|
+
}),
|
|
208
|
+
pendingApp("legacy"),
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const apps = await runWithRequestContext(
|
|
213
|
+
{ userEmail: "dev@example.test" },
|
|
214
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "legacy", "mail"]);
|
|
218
|
+
expect(apps.find((app) => app.id === "mail")?.statusLabel).toBe(
|
|
219
|
+
"Pending Builder branch",
|
|
220
|
+
);
|
|
221
|
+
expect(apps.filter((app) => app.id === "mail")).toHaveLength(1);
|
|
222
|
+
expect(apps.find((app) => app.id === "mail")?.builderUrl).toContain(
|
|
223
|
+
"feature-a",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps unscoped legacy pending rows visible when there is no deploy context", async () => {
|
|
228
|
+
stubNoPendingContext();
|
|
229
|
+
stubManifest();
|
|
230
|
+
mocks.settings.set(settingsKey, {
|
|
231
|
+
pendingApps: [pendingApp("legacy")],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const apps = await runWithRequestContext(
|
|
235
|
+
{ userEmail: "dev@example.test" },
|
|
236
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "legacy"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("hides expired pending Builder app rows", async () => {
|
|
243
|
+
stubManifest();
|
|
244
|
+
vi.stubEnv("BRANCH", "feature-a");
|
|
245
|
+
mocks.settings.set(settingsKey, {
|
|
246
|
+
pendingApps: [
|
|
247
|
+
pendingApp("old-app", {
|
|
248
|
+
contextId: "branch:feature-a",
|
|
249
|
+
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
250
|
+
}),
|
|
251
|
+
pendingApp("fresh-app", {
|
|
252
|
+
contextId: "branch:feature-a",
|
|
253
|
+
}),
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const apps = await runWithRequestContext(
|
|
258
|
+
{ userEmail: "dev@example.test" },
|
|
259
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "fresh-app"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not show a pending row after the app is present in the manifest", async () => {
|
|
266
|
+
stubNoPendingContext();
|
|
267
|
+
stubManifest([
|
|
268
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
269
|
+
{ id: "mail", name: "Mail", path: "/mail" },
|
|
270
|
+
]);
|
|
271
|
+
mocks.settings.set(settingsKey, {
|
|
272
|
+
pendingApps: [pendingApp("mail")],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const apps = await runWithRequestContext(
|
|
276
|
+
{ userEmail: "dev@example.test" },
|
|
277
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "mail"]);
|
|
281
|
+
expect(apps.find((app) => app.id === "mail")?.status).toBe("ready");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("lets workspace admins update app display metadata", async () => {
|
|
285
|
+
stubNoPendingContext();
|
|
286
|
+
stubManifest([
|
|
287
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
288
|
+
{
|
|
289
|
+
id: "todo",
|
|
290
|
+
name: "Todo",
|
|
291
|
+
description: "Original description",
|
|
292
|
+
path: "/todo",
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const updated = await runWithRequestContext(
|
|
297
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
298
|
+
() =>
|
|
299
|
+
updateWorkspaceAppMetadata({
|
|
300
|
+
appId: "todo",
|
|
301
|
+
name: "Todo Board",
|
|
302
|
+
description: "Tracks team work.",
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(updated.name).toBe("Todo Board");
|
|
307
|
+
expect(updated.description).toBe("Tracks team work.");
|
|
308
|
+
expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
|
|
309
|
+
apps: {
|
|
310
|
+
todo: expect.objectContaining({
|
|
311
|
+
name: "Todo Board",
|
|
312
|
+
description: "Tracks team work.",
|
|
313
|
+
updatedBy: "dev@example.test",
|
|
314
|
+
}),
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
expect(mocks.getDbExec).toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("blocks non-admin workspace members from updating app display metadata", async () => {
|
|
321
|
+
mocks.state.orgRole = "member";
|
|
322
|
+
stubNoPendingContext();
|
|
323
|
+
stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
runWithRequestContext(
|
|
327
|
+
{ userEmail: "dev@example.test", orgId: "org-123" },
|
|
328
|
+
() =>
|
|
329
|
+
updateWorkspaceAppMetadata({
|
|
330
|
+
appId: "todo",
|
|
331
|
+
name: "Todo Board",
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
334
|
+
).rejects.toThrow(
|
|
335
|
+
"Only organization owners and admins can update app creation settings.",
|
|
336
|
+
);
|
|
337
|
+
expect(
|
|
338
|
+
mocks.settings.get("workspace-app-metadata:org:org-123"),
|
|
339
|
+
).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
102
342
|
it("generates a concise seed description from an app prompt", () => {
|
|
103
343
|
expect(
|
|
104
344
|
generateWorkspaceAppDescription(
|
|
@@ -34,6 +34,7 @@ const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
|
|
|
34
34
|
const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
|
|
35
35
|
const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 1_000;
|
|
36
36
|
const MAX_PENDING_APPS = 50;
|
|
37
|
+
const PENDING_WORKSPACE_APP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
37
38
|
const AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
38
39
|
const AGENT_CARD_FETCH_TIMEOUT_MS = 1_500;
|
|
39
40
|
const DEFAULT_WORKSPACE_APP_AUDIENCE = "internal";
|
|
@@ -111,9 +112,12 @@ interface PendingWorkspaceApp {
|
|
|
111
112
|
builderUrl: string | null;
|
|
112
113
|
branchName: string | null;
|
|
113
114
|
projectId: string | null;
|
|
115
|
+
contextId: string | null;
|
|
116
|
+
contextLabel: string | null;
|
|
114
117
|
audience?: WorkspaceAppAudience;
|
|
115
118
|
createdAt: string;
|
|
116
119
|
updatedAt: string;
|
|
120
|
+
expiresAt: string | null;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
interface WorkspaceAppMetadataOverride {
|
|
@@ -209,6 +213,48 @@ function workspaceAppMetadataSettingsKey(): string {
|
|
|
209
213
|
return `${WORKSPACE_APP_METADATA_SETTINGS_KEY}:user:${currentOwnerEmail()}`;
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
function cleanOptionalString(value: unknown): string | null {
|
|
217
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeContextPart(value: string): string {
|
|
221
|
+
return value.trim().replace(/\s+/g, " ");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pendingWorkspaceAppContext(): { id: string; label: string } | null {
|
|
225
|
+
const branch =
|
|
226
|
+
cleanOptionalString(process.env.BRANCH) ??
|
|
227
|
+
cleanOptionalString(process.env.HEAD) ??
|
|
228
|
+
cleanOptionalString(process.env.VERCEL_GIT_COMMIT_REF) ??
|
|
229
|
+
cleanOptionalString(process.env.CF_PAGES_BRANCH) ??
|
|
230
|
+
cleanOptionalString(process.env.RENDER_GIT_BRANCH) ??
|
|
231
|
+
cleanOptionalString(process.env.FLY_BRANCH);
|
|
232
|
+
if (branch) {
|
|
233
|
+
const normalized = normalizeContextPart(branch);
|
|
234
|
+
return { id: `branch:${normalized}`, label: `Branch: ${normalized}` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const origin =
|
|
238
|
+
cleanOptionalString(process.env.DEPLOY_PRIME_URL) ??
|
|
239
|
+
cleanOptionalString(process.env.DEPLOY_URL) ??
|
|
240
|
+
cleanOptionalString(process.env.URL) ??
|
|
241
|
+
cleanOptionalString(process.env.APP_URL) ??
|
|
242
|
+
cleanOptionalString(process.env.BETTER_AUTH_URL) ??
|
|
243
|
+
cleanOptionalString(process.env.WORKSPACE_GATEWAY_URL);
|
|
244
|
+
if (!origin) return null;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const parsed = new URL(origin);
|
|
248
|
+
return {
|
|
249
|
+
id: `origin:${parsed.origin}`,
|
|
250
|
+
label: parsed.hostname,
|
|
251
|
+
};
|
|
252
|
+
} catch {
|
|
253
|
+
const normalized = normalizeContextPart(origin);
|
|
254
|
+
return { id: `origin:${normalized}`, label: normalized };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
212
258
|
async function readSettingsRecord(): Promise<Record<string, any>> {
|
|
213
259
|
const raw = await getSetting(scopedSettingsKey()).catch(() => null);
|
|
214
260
|
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
@@ -476,6 +522,61 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
|
|
|
476
522
|
return apps.length ? apps : null;
|
|
477
523
|
}
|
|
478
524
|
|
|
525
|
+
function parseDateMs(value: string | null | undefined): number | null {
|
|
526
|
+
if (!value) return null;
|
|
527
|
+
const ms = Date.parse(value);
|
|
528
|
+
return Number.isFinite(ms) ? ms : null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function pendingWorkspaceAppExpiresAt(createdAt: string): string {
|
|
532
|
+
const createdMs = parseDateMs(createdAt) ?? Date.now();
|
|
533
|
+
return new Date(createdMs + PENDING_WORKSPACE_APP_TTL_MS).toISOString();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isPendingWorkspaceAppExpired(
|
|
537
|
+
app: Pick<PendingWorkspaceApp, "createdAt" | "expiresAt">,
|
|
538
|
+
): boolean {
|
|
539
|
+
const expiresMs =
|
|
540
|
+
parseDateMs(app.expiresAt) ??
|
|
541
|
+
(parseDateMs(app.createdAt) ?? Date.now()) + PENDING_WORKSPACE_APP_TTL_MS;
|
|
542
|
+
return expiresMs <= Date.now();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function pendingWorkspaceAppMatchesCurrentContext(
|
|
546
|
+
app: Pick<PendingWorkspaceApp, "contextId" | "createdAt" | "expiresAt">,
|
|
547
|
+
): boolean {
|
|
548
|
+
if (isPendingWorkspaceAppExpired(app)) return false;
|
|
549
|
+
const currentContext = pendingWorkspaceAppContext();
|
|
550
|
+
if (!app.contextId) return true;
|
|
551
|
+
return app.contextId === currentContext?.id;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function pendingWorkspaceAppContextRank(
|
|
555
|
+
app: Pick<PendingWorkspaceApp, "contextId">,
|
|
556
|
+
): number {
|
|
557
|
+
const currentContext = pendingWorkspaceAppContext();
|
|
558
|
+
if (app.contextId && app.contextId === currentContext?.id) return 2;
|
|
559
|
+
if (!app.contextId) return 1;
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function dedupePendingWorkspaceAppsForCurrentContext(
|
|
564
|
+
apps: PendingWorkspaceApp[],
|
|
565
|
+
): PendingWorkspaceApp[] {
|
|
566
|
+
const byId = new Map<string, PendingWorkspaceApp>();
|
|
567
|
+
for (const app of apps) {
|
|
568
|
+
const existing = byId.get(app.id);
|
|
569
|
+
if (
|
|
570
|
+
!existing ||
|
|
571
|
+
pendingWorkspaceAppContextRank(app) >
|
|
572
|
+
pendingWorkspaceAppContextRank(existing)
|
|
573
|
+
) {
|
|
574
|
+
byId.set(app.id, app);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return Array.from(byId.values());
|
|
578
|
+
}
|
|
579
|
+
|
|
479
580
|
function sortWorkspaceApps(a: WorkspaceAppSummary, b: WorkspaceAppSummary) {
|
|
480
581
|
if (a.id === "dispatch") return -1;
|
|
481
582
|
if (b.id === "dispatch") return 1;
|
|
@@ -495,6 +596,7 @@ function parsePendingWorkspaceApps(value: unknown): PendingWorkspaceApp[] {
|
|
|
495
596
|
typeof record.path === "string" ? record.path.trim() : "";
|
|
496
597
|
if (!id || !pathValue.startsWith("/")) return null;
|
|
497
598
|
const now = new Date().toISOString();
|
|
599
|
+
const createdAt = cleanOptionalString(record.createdAt) ?? now;
|
|
498
600
|
return {
|
|
499
601
|
id,
|
|
500
602
|
name:
|
|
@@ -518,17 +620,19 @@ function parsePendingWorkspaceApps(value: unknown): PendingWorkspaceApp[] {
|
|
|
518
620
|
typeof record.projectId === "string" && record.projectId.trim()
|
|
519
621
|
? record.projectId.trim()
|
|
520
622
|
: null,
|
|
623
|
+
contextId: cleanOptionalString(record.contextId),
|
|
624
|
+
contextLabel: cleanOptionalString(record.contextLabel),
|
|
521
625
|
...(record.audience === undefined
|
|
522
626
|
? {}
|
|
523
627
|
: { audience: normalizeWorkspaceAppAudience(record.audience) }),
|
|
524
|
-
createdAt
|
|
525
|
-
typeof record.createdAt === "string" && record.createdAt.trim()
|
|
526
|
-
? record.createdAt.trim()
|
|
527
|
-
: now,
|
|
628
|
+
createdAt,
|
|
528
629
|
updatedAt:
|
|
529
630
|
typeof record.updatedAt === "string" && record.updatedAt.trim()
|
|
530
631
|
? record.updatedAt.trim()
|
|
531
632
|
: now,
|
|
633
|
+
expiresAt:
|
|
634
|
+
cleanOptionalString(record.expiresAt) ??
|
|
635
|
+
pendingWorkspaceAppExpiresAt(createdAt),
|
|
532
636
|
} satisfies PendingWorkspaceApp;
|
|
533
637
|
})
|
|
534
638
|
.filter((app): app is PendingWorkspaceApp => !!app)
|
|
@@ -597,7 +701,9 @@ export async function removePendingWorkspaceApp(input: {
|
|
|
597
701
|
if (!appId) throw new Error("appId is required");
|
|
598
702
|
const raw = await readSettingsRecord();
|
|
599
703
|
const pending = parsePendingWorkspaceApps(raw.pendingApps);
|
|
600
|
-
const next = pending.filter(
|
|
704
|
+
const next = pending.filter(
|
|
705
|
+
(app) => app.id !== appId || !pendingWorkspaceAppMatchesCurrentContext(app),
|
|
706
|
+
);
|
|
601
707
|
const removed = next.length !== pending.length;
|
|
602
708
|
if (!removed) return { removed: false };
|
|
603
709
|
await putSetting(scopedSettingsKey(), { ...raw, pendingApps: next });
|
|
@@ -622,7 +728,7 @@ function pendingAppToSummary(app: PendingWorkspaceApp): WorkspaceAppSummary {
|
|
|
622
728
|
publicPaths: [],
|
|
623
729
|
protectedPaths: [],
|
|
624
730
|
status: "pending",
|
|
625
|
-
statusLabel: "
|
|
731
|
+
statusLabel: "Pending Builder branch",
|
|
626
732
|
builderUrl: app.builderUrl,
|
|
627
733
|
branchName: app.branchName,
|
|
628
734
|
createdAt: app.createdAt,
|
|
@@ -633,9 +739,11 @@ async function appendPendingWorkspaceApps(
|
|
|
633
739
|
apps: WorkspaceAppSummary[],
|
|
634
740
|
): Promise<WorkspaceAppSummary[]> {
|
|
635
741
|
const readyIds = new Set(apps.map((app) => app.id));
|
|
636
|
-
const pendingApps = (
|
|
637
|
-
|
|
638
|
-
|
|
742
|
+
const pendingApps = dedupePendingWorkspaceAppsForCurrentContext(
|
|
743
|
+
(await listPendingWorkspaceApps())
|
|
744
|
+
.filter(pendingWorkspaceAppMatchesCurrentContext)
|
|
745
|
+
.filter((app) => !readyIds.has(app.id)),
|
|
746
|
+
).map(pendingAppToSummary);
|
|
639
747
|
return [...apps, ...pendingApps].sort(sortWorkspaceApps);
|
|
640
748
|
}
|
|
641
749
|
|
|
@@ -765,9 +873,16 @@ async function recordPendingWorkspaceApp(input: {
|
|
|
765
873
|
builderUrl?: string | null;
|
|
766
874
|
}) {
|
|
767
875
|
const now = new Date().toISOString();
|
|
876
|
+
const context = pendingWorkspaceAppContext();
|
|
768
877
|
const raw = await readSettingsRecord();
|
|
769
878
|
const pendingApps = parsePendingWorkspaceApps(raw.pendingApps);
|
|
770
|
-
const
|
|
879
|
+
const contextId = context?.id ?? null;
|
|
880
|
+
const samePendingEntry = (app: PendingWorkspaceApp) =>
|
|
881
|
+
app.id === input.appId &&
|
|
882
|
+
(app.contextId === contextId || (!!contextId && !app.contextId));
|
|
883
|
+
const existing = pendingApps
|
|
884
|
+
.filter((app) => !isPendingWorkspaceAppExpired(app))
|
|
885
|
+
.find(samePendingEntry);
|
|
771
886
|
const next: PendingWorkspaceApp = {
|
|
772
887
|
id: input.appId,
|
|
773
888
|
name: titleCase(input.appId),
|
|
@@ -778,15 +893,18 @@ async function recordPendingWorkspaceApp(input: {
|
|
|
778
893
|
builderUrl: input.builderUrl?.trim() || null,
|
|
779
894
|
branchName: input.branchName?.trim() || null,
|
|
780
895
|
projectId: input.projectId,
|
|
896
|
+
contextId: context?.id ?? null,
|
|
897
|
+
contextLabel: context?.label ?? null,
|
|
781
898
|
createdAt: existing?.createdAt || now,
|
|
782
899
|
updatedAt: now,
|
|
900
|
+
expiresAt: pendingWorkspaceAppExpiresAt(existing?.createdAt || now),
|
|
783
901
|
};
|
|
784
902
|
|
|
785
903
|
await putSetting(scopedSettingsKey(), {
|
|
786
904
|
...raw,
|
|
787
905
|
pendingApps: [
|
|
788
906
|
next,
|
|
789
|
-
...pendingApps.filter((app) => app
|
|
907
|
+
...pendingApps.filter((app) => !samePendingEntry(app)),
|
|
790
908
|
].slice(0, MAX_PENDING_APPS),
|
|
791
909
|
});
|
|
792
910
|
|
|
@@ -807,6 +925,7 @@ async function recordPendingWorkspaceApp(input: {
|
|
|
807
925
|
builderBranchUrlConfigured: !!next.builderUrl,
|
|
808
926
|
branchName: next.branchName,
|
|
809
927
|
projectIdConfigured: !!next.projectId,
|
|
928
|
+
contextLabel: next.contextLabel,
|
|
810
929
|
},
|
|
811
930
|
});
|
|
812
931
|
}
|