Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/controller/src/store/nexu-config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ const GIFTED_CREDIT_SOURCES = new Set<CreditRechargeRecord["source"]>([
"test",
]);

const GIFTED_CREDIT_SOURCE_HINTS = [
"gift",
"bonus",
"trial",
"promo",
"campaign",
"grant",
];

export type DesktopCloudStateChange = {
hadCloudInventory: boolean;
hasCloudInventory: boolean;
Expand Down Expand Up @@ -573,6 +582,10 @@ function isActiveCreditGrant(
return false;
}

if (typeof grant.expiresAt !== "string" || grant.expiresAt.length === 0) {
return true;
}

const expiresAtTimestamp = Date.parse(grant.expiresAt);
if (!Number.isFinite(expiresAtTimestamp)) {
return true;
Expand All @@ -581,13 +594,32 @@ function isActiveCreditGrant(
return expiresAtTimestamp > nowTimestamp;
}

function isGiftedCreditGrant(grant: CreditRechargeRecord): boolean {
const source = grant.source.trim().toLowerCase();
if (GIFTED_CREDIT_SOURCES.has(source)) {
return true;
}

const searchableValues = [
source,
typeof grant.description === "string"
? grant.description.toLowerCase()
: "",
JSON.stringify(grant.metadata).toLowerCase(),
];

return GIFTED_CREDIT_SOURCE_HINTS.some((hint) =>
searchableValues.some((value) => value.includes(hint)),
);
}

function deriveDesktopBalanceBreakdown(input: {
totalBalance: number;
grants: CreditRechargeRecord[];
}): DesktopBalanceBreakdown & { giftedBalanceRaw: number } {
const nowTimestamp = Date.now();
const giftedBalanceRaw = input.grants.reduce((sum, grant) => {
if (!GIFTED_CREDIT_SOURCES.has(grant.source)) {
if (!isGiftedCreditGrant(grant)) {
return sum;
}

Expand Down
66 changes: 66 additions & 0 deletions apps/controller/tests/cloud-reward-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,72 @@ describe("createCloudRewardService", () => {
expect(result.reason).toBe("parse_error");
});

it("accepts future credit source strings without failing the whole response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
...mockCreditRecordsResponse,
grants: [
{
...mockCreditRecordsResponse.grants[0],
source: "campaign_gift",
},
],
}),
{
status: 200,
},
),
),
);

const service = createCloudRewardService({
cloudUrl: CLOUD_URL,
apiKey: API_KEY,
});
const result = await service.getCreditRecords();

expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.data.grants[0]?.source).toBe("campaign_gift");
});

it("accepts credit records with null expiresAt", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
...mockCreditRecordsResponse,
grants: [
{
...mockCreditRecordsResponse.grants[0],
expiresAt: null,
},
],
}),
{
status: 200,
},
),
),
);

const service = createCloudRewardService({
cloudUrl: CLOUD_URL,
apiKey: API_KEY,
});
const result = await service.getCreditRecords();

expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.data.grants[0]?.expiresAt).toBeNull();
});

it("uses the credit records endpoint", async () => {
let capturedUrl = "";

Expand Down
185 changes: 185 additions & 0 deletions apps/controller/tests/nexu-config-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,191 @@ describe("NexuConfigStore", () => {
}
});

it("classifies future gifted grant sources into gifted balance instead of plan balance", async () => {
await mkdir(path.join(rootDir, ".nexu"), { recursive: true });
await writeFile(
path.join(rootDir, ".nexu", "config.json"),
JSON.stringify(
{
version: 1,
desktop: {
cloud: {
connected: true,
polling: false,
userName: "Cloud User",
userEmail: "user@nexu.io",
connectedAt: "2026-04-01T00:00:00.000Z",
linkUrl: "https://link.nexu.io",
apiKey: "valid-key",
models: [],
},
},
secrets: {},
},
null,
2,
),
"utf8",
);

const store = new NexuConfigStore(env);

let fetchCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
fetchCalls += 1;
if (fetchCalls === 1) {
return new Response(
JSON.stringify({
tasks: [],
progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 },
cloudBalance: {
totalBalance: 480,
totalRecharged: 480,
totalConsumed: 0,
syncedAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
},
}),
{ status: 200 },
);
}

return new Response(
JSON.stringify({
appUserId: "user-1",
grants: [
{
id: "campaign-grant",
appUserId: "user-1",
amount: 480,
balance: 480,
source: "campaign_gift",
sourceId: null,
description: "trial gift",
expiresAt: "2099-04-01T00:00:00.000Z",
enabled: true,
idempotencyKey: "campaign-gift-1",
metadata: {},
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
},
],
usageSummary: {
totalEntries: 0,
totalDueCredits: 0,
totalChargedCredits: 0,
totalCostUsd: "0",
},
}),
{ status: 200 },
);
}),
);

try {
const status = await store.getDesktopRewardsStatus();
expect(status.cloudBalance?.totalBalance).toBe(480);
expect(status.cloudBalance?.giftedBalance).toBe(480);
expect(status.cloudBalance?.planBalance).toBe(0);
} finally {
vi.unstubAllGlobals();
}
});

it("treats gifted grants with null expiresAt as active balance", async () => {
await mkdir(path.join(rootDir, ".nexu"), { recursive: true });
await writeFile(
path.join(rootDir, ".nexu", "config.json"),
JSON.stringify(
{
version: 1,
desktop: {
cloud: {
connected: true,
polling: false,
userName: "Cloud User",
userEmail: "user@nexu.io",
connectedAt: "2026-04-01T00:00:00.000Z",
linkUrl: "https://link.nexu.io",
apiKey: "valid-key",
models: [],
},
},
secrets: {},
},
null,
2,
),
"utf8",
);

const store = new NexuConfigStore(env);

let fetchCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
fetchCalls += 1;
if (fetchCalls === 1) {
return new Response(
JSON.stringify({
tasks: [],
progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 },
cloudBalance: {
totalBalance: 300,
totalRecharged: 300,
totalConsumed: 0,
syncedAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
},
}),
{ status: 200 },
);
}

return new Response(
JSON.stringify({
appUserId: "user-1",
grants: [
{
id: "signup-grant",
appUserId: "user-1",
amount: 300,
balance: 300,
source: "signup_bonus",
sourceId: null,
description: null,
expiresAt: null,
enabled: true,
idempotencyKey: "signup-null-expiry",
metadata: {},
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
},
],
usageSummary: {
totalEntries: 0,
totalDueCredits: 0,
totalChargedCredits: 0,
totalCostUsd: "0",
},
}),
{ status: 200 },
);
}),
);

try {
const status = await store.getDesktopRewardsStatus();
expect(status.cloudBalance?.giftedBalance).toBe(300);
expect(status.cloudBalance?.planBalance).toBe(0);
} finally {
vi.unstubAllGlobals();
}
});

it("falls back to plan-only balance when credit records cannot be loaded", async () => {
await mkdir(path.join(rootDir, ".nexu"), { recursive: true });
await writeFile(
Expand Down
2 changes: 1 addition & 1 deletion apps/controller/tests/skillhub-custom-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const { SkillDb } = await import("../src/services/skillhub/skill-db.js");

function stubExtractTo(
slug: string,
skillsDir: string,
_skillsDir: string,
opts: { withPackageJson?: boolean } = {},
) {
vi.mocked(extractZipMock).mockImplementationOnce(
Expand Down
29 changes: 21 additions & 8 deletions e2e/desktop/tests/packaged-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -631,14 +631,27 @@ async function waitForDesktopReady() {
return r.ok;
}, "web ready");

// Discover actual openclaw port from controller readiness payload or
// fall back to scanning common ports. The port may differ from 18789
// if another service occupied it.
const ocPort = readyPayload?.openclawPort ?? 18789;
await waitFor(async () => {
const r = await fetch(`http://127.0.0.1:${ocPort}/health`);
return r.ok;
}, `openclaw health (port ${ocPort})`);
// Discover actual openclaw port from controller readiness payload,
// runtime-ports.json, or by scanning common ports. The port may differ
// from 18789 if another service occupied it at launch time.
const candidatePorts = [
readyPayload?.openclawPort,
18789,
18790,
18791,
].filter(Boolean);
await waitFor(
async () => {
for (const p of candidatePorts) {
try {
const r = await fetch(`http://127.0.0.1:${p}/health`);
if (r.ok) return true;
} catch {}
}
return false;
},
`openclaw health (ports ${[...new Set(candidatePorts)].join(",")})`,
);
}

// ---------------------------------------------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions packages/shared/src/schemas/credit.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { z } from "zod";

export const creditSourceTypeSchema = z.enum([
export const knownCreditSourceTypeSchema = z.enum([
"signup_bonus",
"daily_bonus",
"github_star",
"social_share",
"test",
]);

export const creditSourceTypeSchema = z.string().min(1);

export type CreditSourceType = z.infer<typeof creditSourceTypeSchema>;
export type KnownCreditSourceType = z.infer<typeof knownCreditSourceTypeSchema>;

export const creditUsageSummarySchema = z.object({
totalEntries: z.number().int().nonnegative(),
Expand Down Expand Up @@ -45,7 +48,7 @@ export const creditRechargeRecordSchema = z.object({
source: creditSourceTypeSchema,
sourceId: z.string().nullable(),
description: z.string().nullable(),
expiresAt: z.string(),
expiresAt: z.string().nullable(),
enabled: z.boolean(),
idempotencyKey: z.string(),
metadata: z.record(z.unknown()),
Expand Down
Loading
Loading