@oxygen-agent/cli 1.1014.19 → 1.1014.81
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/README.md +1 -1
- package/dist/command-manifest.js +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.js +25 -3
- package/dist/index.js +81 -16
- package/dist/json-output.d.ts +3 -0
- package/dist/json-output.js +33 -0
- package/dist/workspace-skill-commands.d.ts +9 -0
- package/dist/workspace-skill-commands.js +135 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +9 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +7 -4
- package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +18 -0
- package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.js +62 -0
- package/node_modules/@oxygen/shared/dist/dev-eval-classifier.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/dev-eval-classifier.js +4 -0
- package/node_modules/@oxygen/shared/dist/dev-eval-context.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/dev-eval-context.js +108 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +5 -1
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +178 -63
- package/node_modules/@oxygen/shared/dist/email-health-status.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/email-health-status.js +16 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +3 -2
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +9 -6
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/langfuse.js +123 -66
- package/node_modules/@oxygen/shared/dist/table-run-summary.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/table-run-summary.js +26 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-skills.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/workspace-skills.js +102 -0
- package/package.json +1 -1
|
@@ -12,10 +12,13 @@ export const TABLE_QUERY_GRID_NATIVE_WINDOW = 10_000;
|
|
|
12
12
|
// Deliberately below Strands' shipped 6000 default; counts the complete host
|
|
13
13
|
// object rather than only its text. This changes no adapter's outer ceiling.
|
|
14
14
|
export const TABLE_QUERY_GRID_BUILTIN_WINDOW = 4_000;
|
|
15
|
-
// The Copilot hands its model the structured result alone,
|
|
16
|
-
// characters
|
|
17
|
-
//
|
|
18
|
-
|
|
15
|
+
// The Copilot hands its model the structured result alone, and stores one over
|
|
16
|
+
// 12,000 characters for its session so the model reads only the part it needs
|
|
17
|
+
// (ADR 0013, amendments 2026-10-10 and 2026-10-11). Its window is therefore the
|
|
18
|
+
// largest page the MCP layer returns untrimmed (MAX_STRUCTURED_CONTENT_CHARS in
|
|
19
|
+
// @oxygen/mcp-server), not what fits in a conversation. One qualified row
|
|
20
|
+
// measured 4,537 on dev, so the Agent window refuses even a single row.
|
|
21
|
+
export const TABLE_QUERY_GRID_COPILOT_WINDOW = 50_000;
|
|
19
22
|
/** The window a built-in host reads tables in; other clients have none. */
|
|
20
23
|
export function tableQueryBuiltinWindow(surface) {
|
|
21
24
|
if (surface === "copilot")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Opt-in at the API boundary; the CLI supplies these defaults. */
|
|
2
|
+
export declare const TABLE_QUERY_RAW_CONTRACT: import("./schema.js").ContractObject<{
|
|
3
|
+
readonly max_chars: {
|
|
4
|
+
type: "integer";
|
|
5
|
+
description?: string;
|
|
6
|
+
minimum?: number;
|
|
7
|
+
maximum?: number;
|
|
8
|
+
"x-clamp"?: true;
|
|
9
|
+
};
|
|
10
|
+
}, never>;
|
|
11
|
+
export declare const TABLE_QUERY_CLI_DEFAULT_LIMIT = 20;
|
|
12
|
+
export declare const TABLE_QUERY_CLI_DEFAULT_MAX_CHARS = 12000;
|
|
13
|
+
export declare function tableQueryRawResultFits(data: Record<string, unknown>, maxChars: number | undefined): boolean;
|
|
14
|
+
export declare function tableQueryRawRecovery(table: string, row?: string): Record<string, unknown>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { success } from "../cli-result.js";
|
|
2
|
+
import { int, obj } from "./schema.js";
|
|
3
|
+
/** Opt-in at the API boundary; the CLI supplies these defaults. */
|
|
4
|
+
export const TABLE_QUERY_RAW_CONTRACT = obj({
|
|
5
|
+
max_chars: int({ minimum: 1_000, maximum: 100_000, description: "Result envelope chars." }),
|
|
6
|
+
});
|
|
7
|
+
export const TABLE_QUERY_CLI_DEFAULT_LIMIT = 20;
|
|
8
|
+
export const TABLE_QUERY_CLI_DEFAULT_MAX_CHARS = 12_000;
|
|
9
|
+
export function tableQueryRawResultFits(data, maxChars) {
|
|
10
|
+
return maxChars === undefined || JSON.stringify(success("tables query", data)).length <= maxChars;
|
|
11
|
+
}
|
|
12
|
+
export function tableQueryRawRecovery(table, row) {
|
|
13
|
+
return {
|
|
14
|
+
table,
|
|
15
|
+
...(row ? { row } : {}),
|
|
16
|
+
next_step: "Reduce fields/limit. Inspect a large cell with `tables rows inspect --table <table> --row <row> --field <field> --projection value --path /row/<field>/<key>`; select a JSON Pointer and continue with value_cursor. MCP: hydrate oxygen_table_row_inspect with oxygen_capabilities_schema, then dispatch it through oxygen_capabilities_read. CLI: --full restores legacy reads; --output <path> keeps full JSON outside context.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OperationSpec } from "./operations.js";
|
|
2
|
+
export declare const WORKSPACE_SKILL_FILE_CONTRACT: import("./schema.js").ContractObject<{
|
|
3
|
+
readonly path: import("./schema.js").StringProperty;
|
|
4
|
+
readonly content: import("./schema.js").StringProperty;
|
|
5
|
+
}, "content" | "path">;
|
|
6
|
+
export declare const WORKSPACE_SKILL_OPERATIONS: readonly OperationSpec[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { arr, int, obj, str } from "./schema.js";
|
|
2
|
+
const name = str({
|
|
3
|
+
minLength: 1,
|
|
4
|
+
maxLength: 100,
|
|
5
|
+
description: "Exact workspace skill name returned by list or import (workspace-…).",
|
|
6
|
+
});
|
|
7
|
+
const file = str({
|
|
8
|
+
minLength: 1,
|
|
9
|
+
maxLength: 240,
|
|
10
|
+
description: "Relative file path; defaults to SKILL.md.",
|
|
11
|
+
});
|
|
12
|
+
const source_url = str({
|
|
13
|
+
maxLength: 2_000,
|
|
14
|
+
description: "Optional HTTPS origin for provenance. Content must be supplied; this does not fetch the URL.",
|
|
15
|
+
});
|
|
16
|
+
export const WORKSPACE_SKILL_FILE_CONTRACT = obj({ path: file, content: str({ maxLength: 120_000 }) }, { required: ["path", "content"] });
|
|
17
|
+
const operation = (verb, method, contract, summary, manualCli = false) => ({
|
|
18
|
+
id: `workspace_skills.${verb}`,
|
|
19
|
+
route: `/api/cli/skills/workspace/${verb}`,
|
|
20
|
+
method,
|
|
21
|
+
contract,
|
|
22
|
+
cli: { path: ["skills", "workspace", verb], summary },
|
|
23
|
+
mcp: {
|
|
24
|
+
name: `oxygen_workspace_skills_${verb}`,
|
|
25
|
+
title: `Workspace skills: ${verb}`,
|
|
26
|
+
description: summary,
|
|
27
|
+
pack: "workspace",
|
|
28
|
+
...(verb === "import" ? { annotations: { openWorldHint: true } } : {}),
|
|
29
|
+
},
|
|
30
|
+
mutates: method !== "GET",
|
|
31
|
+
spends: false,
|
|
32
|
+
...(manualCli ? { generated: { cli: false, mcp: true } } : {}),
|
|
33
|
+
});
|
|
34
|
+
export const WORKSPACE_SKILL_OPERATIONS = [
|
|
35
|
+
operation("list", "GET", obj({
|
|
36
|
+
query: str({ description: "Case-insensitive literal substring of the name or description; short keywords work best." }),
|
|
37
|
+
cursor: str({ description: "Exact next_cursor from the previous list response." }),
|
|
38
|
+
limit: int({ minimum: 1, maximum: 100, description: "Skills per page, from 1 to 100." }),
|
|
39
|
+
}), "Find skills shared with workspace members by literal substring of name or description; short keywords work best; read-only, 0 credits; next_cursor continues the list."),
|
|
40
|
+
operation("get", "GET", obj({ name, file }, { required: ["name"] }), "Read a workspace skill's SKILL.md or one supporting text file, with exact content, revision and the document index. Skills are task guidance; they grant no tool, spend or approval authority."),
|
|
41
|
+
operation("create", "POST", obj({
|
|
42
|
+
name: str({ minLength: 1, maxLength: 64, description: "Lowercase-hyphen name; stored as workspace-<name>." }),
|
|
43
|
+
description: str({ minLength: 1, maxLength: 1_024 }),
|
|
44
|
+
instructions: str({ minLength: 1, maxLength: 120_000 }),
|
|
45
|
+
}, { required: ["name", "description", "instructions"] }), "Create workspace-<name>, visible to this workspace's members; 0 credits, no inference/provider calls or new tool/spend/approval authority. Creates SKILL.md in Knowledge from a lowercase-hyphen name, description and instructions.", true),
|
|
46
|
+
operation("import", "POST", obj({
|
|
47
|
+
name: str({ maxLength: 64 }),
|
|
48
|
+
files: arr(WORKSPACE_SKILL_FILE_CONTRACT, { minItems: 1, maxItems: 50 }),
|
|
49
|
+
url: str({
|
|
50
|
+
maxLength: 2_000,
|
|
51
|
+
description: "Public HTTPS SKILL.md URL (raw text or GitHub file). Choose url or files. A URL imports the entrypoint only; supply files for a complete folder.",
|
|
52
|
+
}),
|
|
53
|
+
source_url,
|
|
54
|
+
}), "Import to workspace members atomically, create-only; ≤50 text files/1 MB; scripts inert; 0 credits, no inference/paid provider calls or new tool/spend/approval authority. Supply root SKILL.md plus supporting text files, or a public HTTPS SKILL.md URL. Preserves content and source. CLI accepts --file, --directory or --url.", true),
|
|
55
|
+
operation("update", "POST", obj({
|
|
56
|
+
name,
|
|
57
|
+
file,
|
|
58
|
+
content: str({ maxLength: 120_000, description: "Full replacement file; SKILL.md must include YAML frontmatter. Its description drives discovery." }),
|
|
59
|
+
expected_revision: int({ minimum: 1, description: "Revision last read; a stale revision refuses the edit." }),
|
|
60
|
+
}, { required: ["name", "content", "expected_revision"] }), "Replace a skill file at its expected revision; read first, stale revisions refuse; 0 credits, no inference/provider calls or new tool/spend/approval authority. Preserves Knowledge history; defaults to SKILL.md."),
|
|
61
|
+
operation("archive", "POST", obj({ name, expected_revision: int({ minimum: 1, description: "Entrypoint revision last read; stale revisions refuse." }) }, { required: ["name", "expected_revision"] }), "Archive a workspace skill, retaining files and Knowledge history; 0 credits, no inference/provider calls or new tool/spend/approval authority. Requires the entrypoint's expected revision; removes it from discovery."),
|
|
62
|
+
];
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const ICP_ACCOUNT = "Is the company this person works for a SMALL SOFTWARE STARTUP based in the UNITED STATES: roughly 2 to 50 employees, seed to Series A, selling software or a software-enabled service? Judge from the company name, domain, industry, size and location fields and from the person's own profile text (headline, about, experience). Answer true only when the evidence supports every part; a large company, a non-software business, or a non-US company is false.";
|
|
2
|
+
export declare const ICP_PERSONA = "Is this person a GO-TO-MARKET ENGINEER, a GROWTH or REVENUE OPERATOR (growth, revenue operations, demand generation, outbound, sales development leadership), or a FOUNDER who runs go-to-market at a startup? Judge from the title, headline and experience. Answer true only when the role clearly carries go-to-market or growth responsibility; an engineer without go-to-market scope, a finance, legal, HR or product role, or an unrelated title is false.";
|
|
3
|
+
export declare const TITLE_FIT = "Is this job title a go-to-market, growth, revenue-operations, sales-leadership, marketing-leadership or founder/CEO role? Answer true for titles such as GTM engineer, head of growth, growth lead, revenue operations, director of sales, VP marketing, founder, CEO, co-founder; false for engineering without go-to-market scope, finance, legal, HR, product, design, support or unrelated titles.";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Exact source question text from scripts/evals/copilot-intent/classifier.mjs.
|
|
2
|
+
export const ICP_ACCOUNT = "Is the company this person works for a SMALL SOFTWARE STARTUP based in the UNITED STATES: roughly 2 to 50 employees, seed to Series A, selling software or a software-enabled service? Judge from the company name, domain, industry, size and location fields and from the person's own profile text (headline, about, experience). Answer true only when the evidence supports every part; a large company, a non-software business, or a non-US company is false.";
|
|
3
|
+
export const ICP_PERSONA = "Is this person a GO-TO-MARKET ENGINEER, a GROWTH or REVENUE OPERATOR (growth, revenue operations, demand generation, outbound, sales development leadership), or a FOUNDER who runs go-to-market at a startup? Judge from the title, headline and experience. Answer true only when the role clearly carries go-to-market or growth responsibility; an engineer without go-to-market scope, a finance, legal, HR or product role, or an unrelated title is false.";
|
|
4
|
+
export const TITLE_FIT = "Is this job title a go-to-market, growth, revenue-operations, sales-leadership, marketing-leadership or founder/CEO role? Answer true for titles such as GTM engineer, head of growth, growth lead, revenue operations, director of sales, VP marketing, founder, CEO, co-founder; false for engineering without go-to-market scope, finance, legal, HR, product, design, support or unrelated titles.";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type DevEvalContext = Readonly<{
|
|
2
|
+
campaignId: string;
|
|
3
|
+
organizationId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
actionRunId?: string;
|
|
6
|
+
/** Installed by the authenticated host after control-DB lookup, never deserialized. */
|
|
7
|
+
fetch: (provider: string, operation: string, input: string | Request | URL, init: RequestInit | undefined, transport: typeof fetch) => Promise<Response>;
|
|
8
|
+
}>;
|
|
9
|
+
export declare const currentDevEvalContext: () => Readonly<{
|
|
10
|
+
campaignId: string;
|
|
11
|
+
organizationId: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
actionRunId?: string;
|
|
14
|
+
/** Installed by the authenticated host after control-DB lookup, never deserialized. */
|
|
15
|
+
fetch: (provider: string, operation: string, input: string | Request | URL, init: RequestInit | undefined, transport: typeof fetch) => Promise<Response>;
|
|
16
|
+
}> | null;
|
|
17
|
+
export declare const withDevEvalContext: <T>(context: DevEvalContext | null, fn: () => T) => T;
|
|
18
|
+
export declare const initializeDevEvalRequest: () => void;
|
|
19
|
+
export declare const enterDevEvalContext: (context: DevEvalContext | null) => void;
|
|
20
|
+
export declare function devEvalRefusal(reason: string, outcome?: "not_applied" | "unknown"): never;
|
|
21
|
+
export declare function devEvalFetch(provider: string, operation: string, input: string | Request | URL, init: RequestInit | undefined, transport?: typeof fetch): Promise<Response>;
|
|
22
|
+
export declare const DEV_EVAL_SAFE_CAPABILITIES: Set<string>;
|
|
23
|
+
/** No approval or model-provided argument can override this trial policy. */
|
|
24
|
+
export declare function assertDevEvalCapability(name: string, args?: Record<string, unknown>): void;
|
|
25
|
+
export declare const DEV_EVAL_BLITZ_OPERATIONS: Set<string>;
|
|
26
|
+
export declare function assertDevEvalTool(toolId: string): void;
|
|
27
|
+
export declare function devEvalActionMetadata(metadata?: Record<string, unknown>): Record<string, unknown>;
|
|
28
|
+
export declare function assertDevEvalOrganization(organizationId: string | null | undefined): void;
|
|
29
|
+
/** Native queued-column entry: unsupported child engines cannot shed attribution. */
|
|
30
|
+
export declare function assertDevEvalColumn(column: {
|
|
31
|
+
kind: string;
|
|
32
|
+
definition: unknown;
|
|
33
|
+
}, actionType?: string): void;
|
|
34
|
+
/** Native host marker only requires a matching ledger; it never creates authority. */
|
|
35
|
+
export declare function assertDevEvalSessionContext(metadata: Record<string, unknown>, context: DevEvalContext | null): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { OxygenError } from "./cli-result.js";
|
|
3
|
+
// Mutable carrier is installed synchronously before the route awaits authentication.
|
|
4
|
+
// The authenticated resolver populates this carrier; enterWith after an await
|
|
5
|
+
// would only alter the resolver continuation and lose attribution in its caller.
|
|
6
|
+
const scope = new AsyncLocalStorage();
|
|
7
|
+
export const currentDevEvalContext = () => scope.getStore()?.context ?? null;
|
|
8
|
+
export const withDevEvalContext = (context, fn) => scope.run({ context }, fn);
|
|
9
|
+
export const initializeDevEvalRequest = () => scope.enterWith({ context: null });
|
|
10
|
+
export const enterDevEvalContext = (context) => {
|
|
11
|
+
const carrier = scope.getStore();
|
|
12
|
+
if (!carrier) {
|
|
13
|
+
if (context)
|
|
14
|
+
devEvalRefusal("request_carrier_missing");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
carrier.context = context;
|
|
18
|
+
};
|
|
19
|
+
export function devEvalRefusal(reason, outcome = "not_applied") {
|
|
20
|
+
const error = new OxygenError("dev_eval_policy_refused", `Dev campaign dispatch refused: ${reason}.`, {
|
|
21
|
+
details: { reason, effect_outcome: outcome, retryable: false }, exitCode: 1,
|
|
22
|
+
});
|
|
23
|
+
Object.assign(error, { retryable: false, effect_outcome: outcome });
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
export async function devEvalFetch(provider, operation, input, init, transport = fetch) {
|
|
27
|
+
const context = currentDevEvalContext();
|
|
28
|
+
return context ? context.fetch(provider, operation, input, init, transport) : transport(input, init);
|
|
29
|
+
}
|
|
30
|
+
export const DEV_EVAL_SAFE_CAPABILITIES = new Set([
|
|
31
|
+
"oxygen_tables_list", "oxygen_tables_sources", "oxygen_tables_describe", "oxygen_tables_preview", "oxygen_tables_query", "oxygen_tables_create", "oxygen_tables_update", "oxygen_tables_rows_add", "oxygen_tables_rows_update",
|
|
32
|
+
"oxygen_columns_catalog", "oxygen_columns_deps", "oxygen_columns_add", "oxygen_columns_update", "oxygen_columns_run", "oxygen_columns_materialize",
|
|
33
|
+
"oxygen_enrichment_catalog", "oxygen_tools_search", "oxygen_tools_describe", "oxygen_tools_check_access", "oxygen_tools_plan_table_batch", "oxygen_tools_run_dry", "oxygen_tools_run_live",
|
|
34
|
+
"oxygen_people_search_preview", "oxygen_people_search_plan", "oxygen_people_search_run", "oxygen_companies_search_preview", "oxygen_companies_search_plan", "oxygen_companies_search_run",
|
|
35
|
+
"oxygen_sequences_list", "oxygen_sequences_create", "oxygen_sequences_draft", "oxygen_sequences_update", "oxygen_sequences_get", "oxygen_sequences_stats", "oxygen_sequences_events", "oxygen_sequences_render", "oxygen_sequences_enroll", "oxygen_sequences_enrollments", "oxygen_sequences_contacts", "oxygen_sequences_analytics"
|
|
36
|
+
]);
|
|
37
|
+
/** No approval or model-provided argument can override this trial policy. */
|
|
38
|
+
export function assertDevEvalCapability(name, args = {}) {
|
|
39
|
+
if (!currentDevEvalContext())
|
|
40
|
+
return;
|
|
41
|
+
if (name === "oxygen_sequences_start" && args.approved === false)
|
|
42
|
+
return; // Native nonexecuting preview remains available.
|
|
43
|
+
if (!DEV_EVAL_SAFE_CAPABILITIES.has(name))
|
|
44
|
+
devEvalRefusal("capability_not_trial_safe");
|
|
45
|
+
if (args.then !== undefined || args.chain !== undefined)
|
|
46
|
+
devEvalRefusal("unsupported_chain_dispatch");
|
|
47
|
+
if (typeof args.max_credits === "number" && (!Number.isFinite(args.max_credits) || args.max_credits > 200))
|
|
48
|
+
devEvalRefusal("declared_credit_ceiling_too_large");
|
|
49
|
+
if (/(send|start|resume|publish|purchase|invite|mailbox|warmup|domain|crm|composio|custom_http|sandbox|infra|workflows|agents_)/i.test(name)) {
|
|
50
|
+
devEvalRefusal("forbidden_effect");
|
|
51
|
+
}
|
|
52
|
+
if (/sequence/i.test(name)) {
|
|
53
|
+
if (!/^oxygen_sequences_(list|create|draft|update|get|stats|events|render|enroll|enrollments|contacts|analytics)$/.test(name))
|
|
54
|
+
devEvalRefusal("sequence_effect_not_draft");
|
|
55
|
+
const inspect = (value) => {
|
|
56
|
+
if (!value || typeof value !== "object")
|
|
57
|
+
return;
|
|
58
|
+
for (const [key, child] of Object.entries(value)) {
|
|
59
|
+
if (/sender|mailbox|account.*id|emailConnection|emailProviderCampaign/i.test(key) && child !== null && child !== undefined && child !== "" && !(Array.isArray(child) && child.length === 0))
|
|
60
|
+
devEvalRefusal("draft_requires_no_sender");
|
|
61
|
+
if (/^(status|state)$/.test(key) && child !== "draft" && child !== undefined && child !== null)
|
|
62
|
+
devEvalRefusal("sequence_requires_draft");
|
|
63
|
+
inspect(child);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
if (args.enroll === true || args.auto_start === true)
|
|
67
|
+
devEvalRefusal("draft_creation_with_automatic_enrollment");
|
|
68
|
+
inspect(args);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export const DEV_EVAL_BLITZ_OPERATIONS = new Set(["company_search", "people_search", "employee_search", "waterfall_person_search", "waterfall_icp_search", "person_enrich", "person_profile_enrich", "company_profile_enrich", "domain_to_linkedin", "company_enrich", "key_info"]);
|
|
72
|
+
export function assertDevEvalTool(toolId) {
|
|
73
|
+
if (!currentDevEvalContext())
|
|
74
|
+
return;
|
|
75
|
+
if (!toolId.startsWith("blitzapi.") || !DEV_EVAL_BLITZ_OPERATIONS.has(toolId.slice(9)))
|
|
76
|
+
devEvalRefusal("data_provider_or_operation");
|
|
77
|
+
}
|
|
78
|
+
export function devEvalActionMetadata(metadata = {}) {
|
|
79
|
+
const { dev_eval: _untrusted, ...rest } = metadata;
|
|
80
|
+
const context = currentDevEvalContext();
|
|
81
|
+
return context ? { ...rest, dev_eval: { campaignId: context.campaignId,
|
|
82
|
+
organizationId: context.organizationId, sessionId: context.sessionId } } : rest;
|
|
83
|
+
}
|
|
84
|
+
export function assertDevEvalOrganization(organizationId) {
|
|
85
|
+
const context = currentDevEvalContext();
|
|
86
|
+
if (context && context.organizationId !== organizationId)
|
|
87
|
+
devEvalRefusal("organization_attribution_mismatch");
|
|
88
|
+
}
|
|
89
|
+
/** Native queued-column entry: unsupported child engines cannot shed attribution. */
|
|
90
|
+
export function assertDevEvalColumn(column, actionType = "tool_column") {
|
|
91
|
+
if (!currentDevEvalContext())
|
|
92
|
+
return;
|
|
93
|
+
if (actionType !== "tool_column" || !['ai', 'research', 'tool', 'formula'].includes(column.kind))
|
|
94
|
+
devEvalRefusal('unsupported_column_child_engine');
|
|
95
|
+
const definition = column.definition;
|
|
96
|
+
if (definition?.mode === 'composio' || definition?.mode === 'custom_http')
|
|
97
|
+
devEvalRefusal('forbidden_column_transport');
|
|
98
|
+
if (column.kind === 'tool')
|
|
99
|
+
assertDevEvalTool(String(definition?.toolId ?? ''));
|
|
100
|
+
}
|
|
101
|
+
/** Native host marker only requires a matching ledger; it never creates authority. */
|
|
102
|
+
export function assertDevEvalSessionContext(metadata, context) {
|
|
103
|
+
const marker = metadata.dev_eval;
|
|
104
|
+
if (!marker)
|
|
105
|
+
return;
|
|
106
|
+
if (typeof marker !== 'object' || !context || marker.campaignId !== context.campaignId)
|
|
107
|
+
devEvalRefusal('registered_session_context_unavailable');
|
|
108
|
+
}
|
|
@@ -21,7 +21,11 @@ export async function acquireDiagnosticStorageLock(path, waitMs = 0) {
|
|
|
21
21
|
database?.close();
|
|
22
22
|
database = undefined;
|
|
23
23
|
const busy = error && typeof error === "object" && "errcode" in error && (error.errcode === 5 || error.errcode === 6);
|
|
24
|
-
|
|
24
|
+
// Only contention is skippable. Preserve permission, capacity and runtime
|
|
25
|
+
// failures for the caller's safe classification rather than calling them busy.
|
|
26
|
+
if (!busy)
|
|
27
|
+
throw error;
|
|
28
|
+
if (performance.now() >= deadline)
|
|
25
29
|
return null;
|
|
26
30
|
// A synchronous SQLite busy timeout would block the same-process owner
|
|
27
31
|
// from finishing its asynchronous fsync and releasing this lock.
|
|
@@ -12,6 +12,125 @@ let warned = false;
|
|
|
12
12
|
let flushing = null;
|
|
13
13
|
let timer;
|
|
14
14
|
let onStored;
|
|
15
|
+
const admissions = new Map();
|
|
16
|
+
class StorageFailure extends Error {
|
|
17
|
+
stage;
|
|
18
|
+
failure;
|
|
19
|
+
constructor(stage, failure) {
|
|
20
|
+
super("Diagnostic storage failed");
|
|
21
|
+
this.stage = stage;
|
|
22
|
+
this.failure = failure;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function storageStage(stage, action) {
|
|
26
|
+
try {
|
|
27
|
+
return await action();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw error instanceof StorageFailure ? error : new StorageFailure(stage, error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function storageFailureFields(error) {
|
|
34
|
+
const stage = error instanceof StorageFailure ? error.stage : "encode";
|
|
35
|
+
const failure = error instanceof StorageFailure ? error.failure : error;
|
|
36
|
+
const value = failure && typeof failure === "object" ? failure : {};
|
|
37
|
+
const code = typeof value.code === "string" && /^(?:E[A-Z0-9_]{1,30}|SQLITE_[A-Z_]{1,30}|spool_full|lock_timeout)$/.test(value.code) ? value.code : null;
|
|
38
|
+
const sqlite = typeof value.errcode === "number" ? value.errcode : null;
|
|
39
|
+
const cause = code === "lock_timeout" ? "lock_timeout" : code === "spool_full" ? "spool_limit"
|
|
40
|
+
: ["EACCES", "EPERM", "EROFS"].includes(code ?? "") || (sqlite !== null && [3, 8, 23].includes(sqlite & 255)) ? "permission"
|
|
41
|
+
: ["ENOSPC", "EDQUOT"].includes(code ?? "") || (sqlite !== null && (sqlite & 255) === 13) ? "filesystem_capacity" : "other";
|
|
42
|
+
const status = value.$metadata?.httpStatusCode;
|
|
43
|
+
return { cause, stage, error_code: code, sqlite_code: sqlite, errno: typeof value.errno === "number" ? value.errno : null,
|
|
44
|
+
http_status: typeof status === "number" && status >= 100 && status <= 599 ? status : null, queue_role: diagnosticRole() };
|
|
45
|
+
}
|
|
46
|
+
function diagnosticRole() {
|
|
47
|
+
const role = process.env.OXYGEN_WORKER_QUEUE_ROLE;
|
|
48
|
+
return role && ["relay", "bulk", "maintenance", "interactive", "provider", "outbound"].includes(role) ? role
|
|
49
|
+
: process.env.OXYGEN_PROCESS_ROLE === "worker" ? "legacy" : "web";
|
|
50
|
+
}
|
|
51
|
+
/** Coalesce local contenders; the kernel mutex still owns cross-process admission. */
|
|
52
|
+
function admitDiagnostic(directory, record, encoded) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const pending = admissions.get(directory);
|
|
55
|
+
const admission = { record, encoded, deadline: performance.now() + 5_000, resolve, reject };
|
|
56
|
+
if (pending)
|
|
57
|
+
pending.pending.push(admission);
|
|
58
|
+
else {
|
|
59
|
+
const queue = { pending: [admission] };
|
|
60
|
+
admissions.set(directory, queue);
|
|
61
|
+
queueMicrotask(() => { void drainAdmissions(directory, queue); });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function drainAdmissions(directory, queue) {
|
|
66
|
+
try {
|
|
67
|
+
while (queue.pending.length) {
|
|
68
|
+
const batch = queue.pending.splice(0, 32);
|
|
69
|
+
let release = null;
|
|
70
|
+
try {
|
|
71
|
+
release = await storageStage("lock", () => acquireDiagnosticStorageLock(join(directory, ".admission.lock"), Math.max(0, batch[0].deadline - performance.now())));
|
|
72
|
+
if (!release)
|
|
73
|
+
throw new StorageFailure("lock", { code: "lock_timeout" });
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
let { used, files } = await storageStage("inspect", () => inspectDiagnosticSpool(directory));
|
|
76
|
+
queue.files = files;
|
|
77
|
+
const scanMs = performance.now() - started;
|
|
78
|
+
for (const admission of batch) {
|
|
79
|
+
try {
|
|
80
|
+
if (performance.now() >= admission.deadline)
|
|
81
|
+
throw new StorageFailure("lock", { code: "lock_timeout" });
|
|
82
|
+
if (used + admission.encoded.compressed.length > DIAGNOSTIC_MAX_BYTES)
|
|
83
|
+
throw new StorageFailure("capacity", { code: "spool_full" });
|
|
84
|
+
// Reserve conservatively even if cleanup of a failed partial write fails.
|
|
85
|
+
used += admission.encoded.compressed.length;
|
|
86
|
+
admission.resolve(await writeDiagnosticSpool(directory, admission.record.id, admission.encoded.compressed));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
admission.reject(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
log("info", "diagnostics.spool_admitted", { queue_role: diagnosticRole(), batch_records: batch.length, spool_files: files.length,
|
|
93
|
+
spool_bytes: used, scan_ms: scanMs, admission_wait_ms: performance.now() - (batch[0].deadline - 5_000) });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
for (const admission of batch)
|
|
97
|
+
admission.reject(error);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await release?.();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
for (const admission of queue.pending.splice(0))
|
|
106
|
+
admission.reject(error);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
admissions.delete(directory);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function writeDiagnosticSpool(directory, id, compressed) {
|
|
113
|
+
const path = join(directory, `${id}.json.gz`);
|
|
114
|
+
const temporary = join(directory, `${id}.${randomUUID()}.tmp`);
|
|
115
|
+
let handle;
|
|
116
|
+
try {
|
|
117
|
+
handle = await storageStage("open", () => open(temporary, "wx", 0o600));
|
|
118
|
+
await storageStage("write", () => handle.writeFile(compressed));
|
|
119
|
+
await storageStage("sync", () => handle.sync());
|
|
120
|
+
await handle.close();
|
|
121
|
+
handle = undefined;
|
|
122
|
+
await storageStage("rename", () => rename(temporary, path));
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
await handle?.close();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await unlink(temporary).catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
15
134
|
export function observeDiagnosticStorage(observer) {
|
|
16
135
|
onStored = observer;
|
|
17
136
|
startDiagnosticRetry();
|
|
@@ -102,40 +221,18 @@ export async function storeDiagnostic(record) {
|
|
|
102
221
|
encoded = encodeDiagnostic(record);
|
|
103
222
|
}
|
|
104
223
|
record = parseDiagnosticRecord(JSON.parse(encoded.bytes.toString("utf8")));
|
|
105
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
106
|
-
|
|
107
|
-
const lockPath = join(directory, ".admission.lock");
|
|
108
|
-
const release = await acquireDiagnosticStorageLock(lockPath, 5_000);
|
|
109
|
-
if (!release)
|
|
110
|
-
throw new Error("Diagnostic spool admission busy");
|
|
111
|
-
try {
|
|
112
|
-
const { used } = await inspectDiagnosticSpool(directory);
|
|
113
|
-
if (used + encoded.compressed.length > DIAGNOSTIC_MAX_BYTES)
|
|
114
|
-
throw new Error("Diagnostic spool is full");
|
|
115
|
-
path = join(directory, `${record.id}.json.gz`);
|
|
116
|
-
const temporary = join(directory, `${record.id}.${randomUUID()}.tmp`);
|
|
117
|
-
const handle = await open(temporary, "wx", 0o600);
|
|
118
|
-
try {
|
|
119
|
-
await handle.writeFile(encoded.compressed);
|
|
120
|
-
await handle.sync();
|
|
121
|
-
}
|
|
122
|
-
finally {
|
|
123
|
-
await handle.close();
|
|
124
|
-
}
|
|
125
|
-
await rename(temporary, path);
|
|
126
|
-
}
|
|
127
|
-
finally {
|
|
128
|
-
await release();
|
|
129
|
-
}
|
|
224
|
+
await storageStage("mkdir", () => mkdir(directory, { recursive: true, mode: 0o700 }));
|
|
225
|
+
path = await admitDiagnostic(directory, record, encoded);
|
|
130
226
|
startDiagnosticRetry();
|
|
131
|
-
const receipt = await uploadEncodedDiagnostic(record, encoded);
|
|
227
|
+
const receipt = await storageStage("upload", () => uploadEncodedDiagnostic(record, encoded));
|
|
132
228
|
await unlink(path).catch(() => undefined);
|
|
133
229
|
return receipt;
|
|
134
230
|
}
|
|
135
|
-
catch {
|
|
231
|
+
catch (error) {
|
|
136
232
|
// Neither storage credentials nor upstream exception text belong in logs.
|
|
137
|
-
|
|
138
|
-
|
|
233
|
+
const fields = storageFailureFields(error);
|
|
234
|
+
log("warn", path ? "diagnostics.capture_deferred" : "diagnostics.capture_lost", { diagnostic_id: record.id, reason: path ? "upload_failed" : "spool_admission_failed", ...fields });
|
|
235
|
+
recordTelemetryCounter(path ? "oxygen.diagnostics.deferred" : "oxygen.diagnostics.lost", 1, { source: record.surface, cause: fields.cause, queue_role: diagnosticRole() });
|
|
139
236
|
return null;
|
|
140
237
|
}
|
|
141
238
|
}
|
|
@@ -155,11 +252,11 @@ async function uploadEncodedDiagnostic(record, encoded) {
|
|
|
155
252
|
Metadata: { sha256: encoded.sha256, bytes: String(encoded.bytes.length), expires_at: new Date(Date.parse(record.started_at) + DIAGNOSTIC_RETENTION_MS).toISOString() },
|
|
156
253
|
}), { abortSignal: AbortSignal.timeout(15_000) });
|
|
157
254
|
log("info", "diagnostics.capture_stored", { ...diagnosticSummary(record), org_id: record.organization_id, user_id: record.user_id,
|
|
158
|
-
diagnostic_key: key, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, sha256: encoded.sha256 });
|
|
255
|
+
diagnostic_key: key, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, sha256: encoded.sha256, queue_role: diagnosticRole() });
|
|
159
256
|
recordTelemetryCounter("oxygen.diagnostics.stored", 1, { source: record.surface, layer: record.layer });
|
|
160
257
|
const receipt = { key, sha256: encoded.sha256, bytes: encoded.bytes.length, compressed_bytes: encoded.compressed.length, record };
|
|
161
258
|
if (onStored)
|
|
162
|
-
await onStored(receipt);
|
|
259
|
+
await storageStage("observer", () => onStored(receipt));
|
|
163
260
|
return receipt;
|
|
164
261
|
}
|
|
165
262
|
export function flushDiagnosticSpool() {
|
|
@@ -169,63 +266,81 @@ export function flushDiagnosticSpool() {
|
|
|
169
266
|
if (!diagnosticsConfigured())
|
|
170
267
|
return;
|
|
171
268
|
const directory = process.env.OXYGEN_DIAGNOSTICS_SPOOL_DIR;
|
|
172
|
-
const release = await acquireDiagnosticStorageLock(join(directory, ".upload.lock"));
|
|
269
|
+
const release = await storageStage("lock", () => acquireDiagnosticStorageLock(join(directory, ".upload.lock")));
|
|
173
270
|
if (!release)
|
|
174
271
|
return;
|
|
175
272
|
try {
|
|
176
273
|
// Idle workers enforce the same expiry as new admissions. The admission
|
|
177
274
|
// lock protects cleanup against a writer replacing an old record path.
|
|
178
|
-
const admission = await acquireDiagnosticStorageLock(join(directory, ".admission.lock"));
|
|
179
|
-
if (!admission)
|
|
180
|
-
return;
|
|
181
275
|
let files;
|
|
182
|
-
|
|
183
|
-
|
|
276
|
+
const local = admissions.get(directory);
|
|
277
|
+
if (local) {
|
|
278
|
+
// Reuse the active writer's protected inspection. Waiting for local idle
|
|
279
|
+
// can starve retained evidence indefinitely under sustained capture.
|
|
280
|
+
files = local.files?.slice(0, 20) ?? [];
|
|
184
281
|
}
|
|
185
|
-
|
|
186
|
-
await
|
|
282
|
+
else {
|
|
283
|
+
const admission = await storageStage("lock", () => acquireDiagnosticStorageLock(join(directory, ".admission.lock")));
|
|
284
|
+
if (!admission)
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
files = (await storageStage("inspect", () => inspectDiagnosticSpool(directory))).files.slice(0, 20);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await admission();
|
|
291
|
+
}
|
|
187
292
|
}
|
|
188
293
|
for (const name of files) {
|
|
189
294
|
const path = join(directory, name);
|
|
190
295
|
try {
|
|
191
|
-
const compressed = await readFile(path);
|
|
192
|
-
const record = decodeDiagnostic(compressed);
|
|
193
|
-
await uploadEncodedDiagnostic(record, encodeDiagnostic(record));
|
|
296
|
+
const compressed = await storageStage("read", () => readFile(path));
|
|
297
|
+
const record = await storageStage("decode", async () => decodeDiagnostic(compressed));
|
|
298
|
+
await storageStage("upload", () => uploadEncodedDiagnostic(record, encodeDiagnostic(record)));
|
|
194
299
|
await unlink(path).catch(() => undefined);
|
|
195
300
|
}
|
|
196
|
-
catch {
|
|
301
|
+
catch (error) {
|
|
302
|
+
log("warn", "diagnostics.spool_upload_deferred", { diagnostic_id: name.slice(0, -8), ...storageFailureFields(error) });
|
|
303
|
+
}
|
|
197
304
|
}
|
|
198
305
|
}
|
|
199
306
|
finally {
|
|
200
307
|
await release();
|
|
201
308
|
}
|
|
202
|
-
})().catch(
|
|
309
|
+
})().catch(error => { log("warn", "diagnostics.spool_retry_failed", storageFailureFields(error)); }).finally(() => { flushing = null; });
|
|
203
310
|
return flushing;
|
|
204
311
|
}
|
|
205
312
|
/** Caller holds admission lock; count retained bytes and expire actual filenames. */
|
|
206
313
|
async function inspectDiagnosticSpool(directory) {
|
|
207
314
|
let used = 0;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
315
|
+
const retained = [];
|
|
316
|
+
const names = (await readdir(directory)).filter(name => /^[0-9a-f-]+\.json\.gz$/.test(name) || /^[0-9a-f-]+(?:\.[0-9a-f-]+)?\.tmp$/.test(name));
|
|
317
|
+
for (let offset = 0; offset < names.length; offset += 64) {
|
|
318
|
+
const inspected = await Promise.all(names.slice(offset, offset + 64).map(async (name) => {
|
|
319
|
+
const path = join(directory, name);
|
|
320
|
+
const info = await stat(path).catch(error => { if (error?.code === "ENOENT")
|
|
321
|
+
return null; throw error; });
|
|
322
|
+
return { name, path, info };
|
|
323
|
+
}));
|
|
324
|
+
for (const { name, path, info } of inspected) {
|
|
325
|
+
const record = /^[0-9a-f-]+\.json\.gz$/.test(name);
|
|
326
|
+
if (!info)
|
|
327
|
+
continue;
|
|
328
|
+
if (Date.now() - info.mtimeMs >= 72 * 60 * 60 * 1000) {
|
|
329
|
+
const removed = await unlink(path).then(() => true).catch(error => { if (error?.code === "ENOENT")
|
|
330
|
+
return false; throw error; });
|
|
331
|
+
if (removed) {
|
|
332
|
+
log("warn", "diagnostics.capture_lost", { reason: "spool_expired", bytes: info.size, queue_role: diagnosticRole() });
|
|
333
|
+
recordTelemetryCounter("oxygen.diagnostics.lost", 1, { reason: "spool_expired" });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
used += info.size;
|
|
338
|
+
if (record)
|
|
339
|
+
retained.push({ name, mtimeMs: info.mtimeMs });
|
|
340
|
+
}
|
|
226
341
|
}
|
|
227
342
|
}
|
|
228
|
-
return { used, files };
|
|
343
|
+
return { used, files: retained.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name)).map(file => file.name) };
|
|
229
344
|
}
|
|
230
345
|
/** A metadata-only fallback for an unsupported value never claims full capture. */
|
|
231
346
|
export function partialDiagnostic(record, reason) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Health polling and measured evidence are independent clocks. Legacy checked_at
|
|
2
|
+
* values also advanced on empty polls and cannot establish measurement freshness. */
|
|
3
|
+
export type EmailHealthSyncStatus = "unknown" | "ok" | "blocked" | "failed";
|
|
4
|
+
export type EmailHealthSyncReason = "byok_requires_paid_plan" | "provider_read_failed";
|
|
5
|
+
export type EmailHealthFreshness = "current" | "stale" | "unmeasured";
|
|
6
|
+
export declare function readEmailHealthStatus(metadata: Record<string, unknown> | null | undefined, now?: number): {
|
|
7
|
+
evidenceAt: Date | null;
|
|
8
|
+
freshness: EmailHealthFreshness;
|
|
9
|
+
syncStatus: EmailHealthSyncStatus;
|
|
10
|
+
syncReason: EmailHealthSyncReason | null;
|
|
11
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function readEmailHealthStatus(metadata, now = Date.now()) {
|
|
2
|
+
const evidence = metadata?.health_evidence_at;
|
|
3
|
+
const timestamp = typeof evidence === "string" && /^\d{4}-\d{2}-\d{2}T/.test(evidence)
|
|
4
|
+
? Date.parse(evidence) : NaN;
|
|
5
|
+
const evidenceAt = Number.isFinite(timestamp) && timestamp <= now ? new Date(timestamp) : null;
|
|
6
|
+
const status = metadata?.health_sync_status;
|
|
7
|
+
const syncStatus = status === "ok" || status === "blocked" || status === "failed" ? status : "unknown";
|
|
8
|
+
const reason = metadata?.health_sync_reason;
|
|
9
|
+
const syncReason = reason === "byok_requires_paid_plan" || reason === "provider_read_failed" ? reason : null;
|
|
10
|
+
return {
|
|
11
|
+
evidenceAt,
|
|
12
|
+
freshness: evidenceAt ? now - timestamp <= 24 * 60 * 60 * 1000 ? "current" : "stale" : "unmeasured",
|
|
13
|
+
syncStatus,
|
|
14
|
+
syncReason,
|
|
15
|
+
};
|
|
16
|
+
}
|