mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(billing): support Coding Plan usage and wallet fallback * fix(web): keep AMR send off billing preflight Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(billing): serialize recovery preflight and bound hung vela Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after ten minutes even if a preflight is still pending. Give vela billing preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(web): restore coding plan text contrast in billing card * feat(web): render Coding Plan windows and Go tier in the credits panel - CodingPlanUsage renders one row per preflight window (name, used %, remaining USD, reset countdown + absolute time) with loading/unavailable states; refreshes once on mount and once at resetsAt (no 30s polling, setTimeout delay clamped to the 32-bit limit). - Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from `vela billing summary` through WorkspaceBillingSummary and falls back to 10,000 only when the backend omits it. - PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark, design asset still pending). - i18n: 7 new billing keys + entry.billingTierGo across all 19 locales; real translations for the #8339 keys; drop unused codingPlanNone / codingPlanRemaining. - CLI wording: unstarted window reads "not started yet". * build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight The packaged client needs `vela billing preflight` for the Coding Plan window rows. Bump the bundled CLI to the npm `test` prerelease that ships it and add a third capability gate in tools-pack: a CLI without the subcommand prints the parent `billing` help and exits 0, so the gate checks the help markers (preflight, --workspace-id, --model, --format) instead of the exit code. TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is released; do not merge with a `test` prerelease pinned. * fix(web): make Coding Plan rows legible on the inverted credits card The credits card is a hard-coded #202020/#fafafa inverted surface, but CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens, so on the card the window names rendered #202020 on #202020 (1.00:1) and the stat/reset lines sat below WCAG AA. Derive every colour from currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%, track 36%, exhausted = --red mixed toward currentColor); measured in Chrome: name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1. Keep the stat on one line (nowrap) and let the name ellipsize instead of wrapping "60% / used" onto two lines; drop the block's 12px side padding so it aligns with the card's own rows. Also add a minutes tier to the reset countdown: the last hour before a window resets used to show no countdown at all. New key billing.codingPlanResetsInMinutes in all 19 locales. * feat(web): rebuild the plan panel 1:1 from design PR #8364 Replace the improvised Coding Plan block with the design's panel, measured against docs/ui-previews/plan-panels/index.html in Chrome (computed styles and a 4x pixel diff of the Plus specimen): - Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier). - One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent; 100% when spent, no red state) over a 5px #b1f38b track, then the separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names, remaining USD, reset time, note or unavailable line. - Team workspaces keep the pre-existing card unchanged (all team_* tiers); the quota row renders only for personal workspaces. - Free personal workspace: no quota row, but the header sells 升级 like the design (a personal workspace needs no billing permission; an account B reports without a plan upgrades as free). - Top-right pill shows the wordmark only, no balance. - Wallet always reads US$ regardless of locale (product ruling 2026-09-23). - zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版. - Drop the dead billing.codingPlan* keys and model helpers; add billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales. * feat(web): show 管理 on personal Max per design PR #8364 The design's Max card sells 管理 (console auto-recharge settings) where every other tier sells 升级; product ruled on 2026-09-23 to follow the design for personal Max too, superseding the earlier "personal tiers are always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins over 升级 when both would show. * feat(web): route the plan card's three entries per product ruling 2026-09-23 ruling on the card's landings: 升级 opens the console's pricing dialog (billing=plan, unchanged), the allowance row opens the console dashboard (unchanged), the wallet row asks for the manual top-up dialog (new billing=recharge intent; B does not honor it yet and degrades to the dashboard), and 管理 on Max opens the plain dashboard instead of the auto-recharge dialog. * fix(web): align personal plan cards with v2 design and scoped loading * fix(web): center plan wordmarks inside the top-right pill * fix(web): reuse shared billing cache for plan hover cards * test(web): match personal billing preflight in avatar fixture * fix(billing): require usable quota evidence for funding recovery * test(billing): cover scoped preflight and quota event refresh end to end * feat(daemon): relay scoped coding plan usage events and health * fix(billing): refresh quota caches from scoped realtime events * test(daemon): accept capability query on mock event stream --------- Co-authored-by: lefarcen <935902669@qq.com>
301 lines
9.9 KiB
TypeScript
301 lines
9.9 KiB
TypeScript
import type { Server } from 'node:http';
|
|
import { createServer } from 'node:http';
|
|
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
type StartedServer = {
|
|
url: string;
|
|
server: Server;
|
|
shutdown?: () => Promise<void> | void;
|
|
};
|
|
|
|
const WORKSPACE_ID = 'workspace-team';
|
|
const MEMBER_ID = 'member-admin';
|
|
const MANAGED_ENV = [
|
|
'AMR_HOME',
|
|
'OD_COLLAB_TRANSPORT',
|
|
'OD_DATA_DIR',
|
|
'OD_RESOURCE_TRANSPORT',
|
|
'OD_TEAM_PROJECTS_TRANSPORT',
|
|
'OD_WORKSPACE_AUTHORITY_CACHE_MODE',
|
|
'OD_WORKSPACE_CONTEXT_SOURCE',
|
|
'VELA_API_URL',
|
|
'VELA_BIN',
|
|
'VELA_CONTROL_KEY',
|
|
] as const;
|
|
|
|
let authority: Server | null = null;
|
|
let daemon: StartedServer | null = null;
|
|
let scratch: string | null = null;
|
|
const savedEnv = new Map<string, string | undefined>();
|
|
|
|
afterEach(async () => {
|
|
if (daemon) {
|
|
const current = daemon;
|
|
daemon = null;
|
|
await Promise.resolve(current.shutdown?.());
|
|
current.server.closeAllConnections?.();
|
|
current.server.closeIdleConnections?.();
|
|
if (current.server.listening) {
|
|
await new Promise<void>((resolve) => current.server.close(() => resolve()));
|
|
}
|
|
}
|
|
if (authority) {
|
|
const current = authority;
|
|
authority = null;
|
|
current.closeAllConnections?.();
|
|
current.closeIdleConnections?.();
|
|
if (current.listening) {
|
|
await new Promise<void>((resolve) => current.close(() => resolve()));
|
|
}
|
|
}
|
|
if (scratch) await rm(scratch, { recursive: true, force: true });
|
|
scratch = null;
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
savedEnv.clear();
|
|
vi.resetModules();
|
|
}, 30_000);
|
|
|
|
describe('server workspace context authority wiring', () => {
|
|
it('keeps directory Team authority and fences an unread A -> B -> A Settings transition', async () => {
|
|
scratch = await mkdtemp(join(tmpdir(), 'od-context-authority-wiring-'));
|
|
let directoryReads = 0;
|
|
let allowHubReady = true;
|
|
const authorityUrl = await startAuthority({
|
|
onDirectory: () => {
|
|
directoryReads += 1;
|
|
},
|
|
isHubReady: () => allowHubReady,
|
|
});
|
|
const velaBin = await writeVelaStub(scratch);
|
|
setEnv({
|
|
AMR_HOME: join(scratch, 'empty-amr-home'),
|
|
OD_COLLAB_TRANSPORT: 'off',
|
|
OD_DATA_DIR: join(scratch, 'data'),
|
|
OD_RESOURCE_TRANSPORT: 'off',
|
|
OD_TEAM_PROJECTS_TRANSPORT: 'off',
|
|
OD_WORKSPACE_AUTHORITY_CACHE_MODE: 'adaptive',
|
|
OD_WORKSPACE_CONTEXT_SOURCE: 'vela',
|
|
VELA_API_URL: authorityUrl,
|
|
VELA_BIN: velaBin,
|
|
VELA_CONTROL_KEY: 'context-authority-control-key',
|
|
});
|
|
|
|
vi.resetModules();
|
|
const serverModule = (await import('../src/server.js')) as unknown as {
|
|
startServer(options: { port: number; returnServer: true }): Promise<StartedServer>;
|
|
};
|
|
daemon = await serverModule.startServer({ port: 0, returnServer: true });
|
|
|
|
const initial = await getWorkspaceContext();
|
|
expect(initial).toMatchObject({ workspaceType: 'team', role: 'admin' });
|
|
const directoryReadsAfterInitial = directoryReads;
|
|
expect(directoryReadsAfterInitial).toBeGreaterThan(0);
|
|
|
|
const interest = await fetch(`${daemon.url}/api/workspace/billing/interests/context-test`, {
|
|
method: 'PUT',
|
|
headers: { ...workspaceHeaders(), 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
generation: '1',
|
|
interests: [{ workspaceId: WORKSPACE_ID, workspaceMemberId: MEMBER_ID }],
|
|
}),
|
|
});
|
|
expect(interest.status).toBe(200);
|
|
await vi.waitFor(
|
|
() => expect(directoryReads).toBeGreaterThan(directoryReadsAfterInitial),
|
|
{ timeout: 10_000, interval: 50 },
|
|
);
|
|
|
|
let warmContext: Record<string, unknown> | null = null;
|
|
await vi.waitFor(async () => {
|
|
const readsBefore = directoryReads;
|
|
const context = await getWorkspaceContext();
|
|
expect(directoryReads).toBe(readsBefore);
|
|
warmContext = context;
|
|
}, { timeout: 10_000, interval: 50 });
|
|
|
|
expect(warmContext).not.toBeNull();
|
|
expect(warmContext).toMatchObject({
|
|
workspaceId: WORKSPACE_ID,
|
|
workspaceMemberId: MEMBER_ID,
|
|
workspaceType: 'team',
|
|
role: 'admin',
|
|
teamId: WORKSPACE_ID,
|
|
planId: null,
|
|
});
|
|
const directoryReadsAfterCatchUp = directoryReads;
|
|
await getWorkspaceContext();
|
|
await getWorkspaceContext();
|
|
expect(directoryReads).toBe(directoryReadsAfterCatchUp);
|
|
|
|
// Red spec for the account-identity fence: both Settings writes complete
|
|
// without any directory/status read. The final credential identity is
|
|
// byte-for-byte A again, so a cache keyed only by the current identity
|
|
// would otherwise revive the old five-minute authority lease.
|
|
allowHubReady = false;
|
|
const directoryReadsBeforeCredentialRoundTrip = directoryReads;
|
|
await putAmrApiUrl('https://account-b.example');
|
|
await putAmrApiUrl(authorityUrl);
|
|
expect(directoryReads).toBe(directoryReadsBeforeCredentialRoundTrip);
|
|
|
|
const afterCredentialRoundTrip = await getWorkspaceContext();
|
|
expect(afterCredentialRoundTrip).toMatchObject({
|
|
workspaceId: WORKSPACE_ID,
|
|
workspaceMemberId: MEMBER_ID,
|
|
role: 'admin',
|
|
});
|
|
expect(directoryReads).toBeGreaterThan(directoryReadsBeforeCredentialRoundTrip);
|
|
}, 60_000);
|
|
});
|
|
|
|
function setEnv(values: Record<string, string>): void {
|
|
for (const key of MANAGED_ENV) {
|
|
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
|
|
delete process.env[key];
|
|
}
|
|
for (const [key, value] of Object.entries(values)) process.env[key] = value;
|
|
}
|
|
|
|
function workspaceHeaders(): Record<string, string> {
|
|
return {
|
|
'x-od-workspace-id': WORKSPACE_ID,
|
|
'x-od-workspace-type': 'team',
|
|
'x-od-workspace-member-id': MEMBER_ID,
|
|
'x-od-workspace-role': 'admin',
|
|
'x-od-workspace-member-status': 'active',
|
|
'x-od-workspace-lifecycle-state': 'active',
|
|
'x-od-workspace-can-share-projects': 'true',
|
|
'x-od-workspace-can-write-synced-files': 'true',
|
|
};
|
|
}
|
|
|
|
async function getWorkspaceContext(): Promise<Record<string, unknown>> {
|
|
const response = await fetch(`${daemon!.url}/api/workspace/context`, {
|
|
headers: workspaceHeaders(),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json() as { context: Record<string, unknown> };
|
|
return body.context;
|
|
}
|
|
|
|
async function putAmrApiUrl(apiUrl: string): Promise<void> {
|
|
const response = await fetch(`${daemon!.url}/api/app-config`, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentCliEnv: {
|
|
amr: { VELA_API_URL: apiUrl },
|
|
},
|
|
}),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
}
|
|
|
|
async function startAuthority(callbacks: {
|
|
onDirectory: () => void;
|
|
isHubReady?: () => boolean;
|
|
}): Promise<string> {
|
|
authority = createServer((req, res) => {
|
|
if (req.url === '/api/v1/workspaces' && req.method === 'GET') {
|
|
callbacks.onDirectory();
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
items: [{
|
|
workspaceId: WORKSPACE_ID,
|
|
workspaceName: 'Verified team',
|
|
workspaceType: 'team',
|
|
workspaceMemberId: MEMBER_ID,
|
|
role: 'admin',
|
|
memberStatus: 'active',
|
|
lifecycleState: 'active',
|
|
}],
|
|
}));
|
|
return;
|
|
}
|
|
const pathname = new URL(req.url ?? '/', 'http://authority.test').pathname;
|
|
if (pathname === '/api/v1/collab/events' && req.method === 'GET') {
|
|
res.writeHead(200, {
|
|
'cache-control': 'no-cache',
|
|
connection: 'keep-alive',
|
|
'content-type': 'text/event-stream',
|
|
});
|
|
if (callbacks.isHubReady?.() === false) return;
|
|
res.write(
|
|
`event: ready\ndata: ${JSON.stringify({
|
|
workspaceId: WORKSPACE_ID,
|
|
capabilities: [
|
|
'workspace-member-events-v1',
|
|
'workspace-event-listener-status-v1',
|
|
],
|
|
listenerEpoch: 'authority-test',
|
|
listenerHealth: 'healthy',
|
|
sourceGap: false,
|
|
})}\n\n`,
|
|
);
|
|
res.write(
|
|
'event: heartbeat\ndata: ' + JSON.stringify({
|
|
listenerEpoch: 'authority-test',
|
|
listenerHealth: 'healthy',
|
|
sourceGap: false,
|
|
}) + '\n\n',
|
|
);
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'not_found' }));
|
|
});
|
|
await new Promise<void>((resolve) => authority!.listen(0, '127.0.0.1', resolve));
|
|
const address = authority.address();
|
|
if (address == null || typeof address === 'string') {
|
|
throw new Error('workspace authority has no port');
|
|
}
|
|
return `http://127.0.0.1:${address.port}`;
|
|
}
|
|
|
|
async function writeVelaStub(root: string): Promise<string> {
|
|
const script = join(root, 'vela-stub.mjs');
|
|
await writeFile(
|
|
script,
|
|
`const args = process.argv.slice(2);
|
|
if (args[0] === 'billing' && args[1] === 'summary') {
|
|
process.stdout.write(JSON.stringify({
|
|
membershipTier: 'team',
|
|
balanceUsd: '0.00',
|
|
subscriptionStatus: 'active',
|
|
balances: { totalAvailableCredits: 0, subscriptionCredits: 0, rechargeCredits: 0 },
|
|
availableActions: [],
|
|
}) + '\\n');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'billing' && args[1] === 'workspace-snapshot') {
|
|
process.stdout.write(JSON.stringify({
|
|
schemaVersion: 1,
|
|
workspaceId: '${WORKSPACE_ID}',
|
|
workspaceMemberId: '${MEMBER_ID}',
|
|
billingScopeVersion: 2,
|
|
billing: { billingState: 'active', planId: 'team_pro' },
|
|
wallet: { balanceUsd: '10.00', expiresAt: null, updatedAt: '2026-08-13T00:00:00Z' },
|
|
revisions: { billing: 'billing-1', wallet: 'wallet-1' },
|
|
}) + '\\n');
|
|
process.exit(0);
|
|
}
|
|
process.stdout.write(JSON.stringify([]) + '\\n');
|
|
`,
|
|
'utf8',
|
|
);
|
|
const bin = join(root, 'vela');
|
|
await writeFile(
|
|
bin,
|
|
`#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`,
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
return bin;
|
|
}
|