Files
open-design/apps/daemon/tests/workspace-context-authority-server-wiring.test.ts
eb27acd322 feat(billing): support Coding Plan usage and wallet fallback (#8339)
* 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>
2026-09-24 03:22:31 +00:00

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;
}