selftune 0.2.6 → 0.2.9
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 -0
- package/apps/local-dashboard/dist/assets/index-Bs3Y4ixf.css +1 -0
- package/apps/local-dashboard/dist/assets/index-C4UYGWKr.js +15 -0
- package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
- package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
- package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
- package/apps/local-dashboard/dist/index.html +5 -5
- package/cli/selftune/activation-rules.ts +57 -18
- package/cli/selftune/agent-guidance.ts +96 -0
- package/cli/selftune/alpha-identity.ts +156 -0
- package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
- package/cli/selftune/alpha-upload/client.ts +113 -0
- package/cli/selftune/alpha-upload/flush.ts +191 -0
- package/cli/selftune/alpha-upload/index.ts +194 -0
- package/cli/selftune/alpha-upload/queue.ts +252 -0
- package/cli/selftune/alpha-upload/stage-canonical.ts +251 -0
- package/cli/selftune/alpha-upload-contract.ts +52 -0
- package/cli/selftune/auth/device-code.ts +110 -0
- package/cli/selftune/auto-update.ts +130 -0
- package/cli/selftune/badge/badge.ts +19 -9
- package/cli/selftune/canonical-export.ts +16 -3
- package/cli/selftune/constants.ts +28 -8
- package/cli/selftune/contribute/bundle.ts +33 -5
- package/cli/selftune/dashboard-contract.ts +32 -1
- package/cli/selftune/dashboard-server.ts +215 -693
- package/cli/selftune/dashboard.ts +1 -1
- package/cli/selftune/eval/baseline.ts +11 -7
- package/cli/selftune/eval/hooks-to-evals.ts +39 -15
- package/cli/selftune/eval/synthetic-evals.ts +54 -1
- package/cli/selftune/evolution/audit.ts +24 -19
- package/cli/selftune/evolution/constitutional.ts +176 -0
- package/cli/selftune/evolution/evidence.ts +18 -13
- package/cli/selftune/evolution/evolve-body.ts +104 -7
- package/cli/selftune/evolution/evolve.ts +195 -22
- package/cli/selftune/evolution/propose-body.ts +18 -1
- package/cli/selftune/evolution/propose-description.ts +27 -2
- package/cli/selftune/evolution/rollback.ts +11 -15
- package/cli/selftune/export.ts +84 -0
- package/cli/selftune/grading/auto-grade.ts +14 -4
- package/cli/selftune/grading/grade-session.ts +17 -6
- package/cli/selftune/hooks/auto-activate.ts +5 -0
- package/cli/selftune/hooks/evolution-guard.ts +25 -11
- package/cli/selftune/hooks/prompt-log.ts +23 -9
- package/cli/selftune/hooks/session-stop.ts +78 -15
- package/cli/selftune/hooks/skill-eval.ts +189 -10
- package/cli/selftune/index.ts +274 -2
- package/cli/selftune/ingestors/claude-replay.ts +48 -21
- package/cli/selftune/init.ts +260 -49
- package/cli/selftune/last.ts +7 -7
- package/cli/selftune/localdb/db.ts +90 -10
- package/cli/selftune/localdb/direct-write.ts +573 -0
- package/cli/selftune/localdb/materialize.ts +296 -42
- package/cli/selftune/localdb/queries.ts +482 -32
- package/cli/selftune/localdb/schema.ts +153 -1
- package/cli/selftune/monitoring/watch.ts +27 -8
- package/cli/selftune/normalization.ts +88 -15
- package/cli/selftune/observability.ts +257 -5
- package/cli/selftune/orchestrate.ts +176 -53
- package/cli/selftune/quickstart.ts +34 -10
- package/cli/selftune/repair/skill-usage.ts +15 -2
- package/cli/selftune/routes/actions.ts +77 -0
- package/cli/selftune/routes/badge.ts +66 -0
- package/cli/selftune/routes/doctor.ts +12 -0
- package/cli/selftune/routes/index.ts +14 -0
- package/cli/selftune/routes/orchestrate-runs.ts +13 -0
- package/cli/selftune/routes/overview.ts +14 -0
- package/cli/selftune/routes/report.ts +293 -0
- package/cli/selftune/routes/skill-report.ts +230 -0
- package/cli/selftune/status.ts +203 -7
- package/cli/selftune/sync.ts +14 -1
- package/cli/selftune/types.ts +52 -2
- package/cli/selftune/utils/jsonl.ts +58 -1
- package/cli/selftune/utils/selftune-meta.ts +38 -0
- package/cli/selftune/utils/skill-log.ts +30 -4
- package/cli/selftune/utils/transcript.ts +15 -0
- package/cli/selftune/workflows/workflows.ts +7 -6
- package/package.json +11 -6
- package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
- package/packages/telemetry-contract/fixtures/golden.json +1 -0
- package/packages/telemetry-contract/fixtures/index.ts +4 -0
- package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
- package/packages/telemetry-contract/package.json +6 -1
- package/packages/telemetry-contract/src/schemas.ts +196 -0
- package/packages/telemetry-contract/src/types.ts +3 -1
- package/packages/telemetry-contract/src/validators.ts +3 -1
- package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
- package/packages/ui/package.json +4 -0
- package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
- package/packages/ui/src/components/section-cards.tsx +31 -14
- package/packages/ui/src/types.ts +1 -0
- package/skill/SKILL.md +214 -174
- package/skill/Workflows/AlphaUpload.md +45 -0
- package/skill/Workflows/Baseline.md +18 -12
- package/skill/Workflows/Composability.md +3 -3
- package/skill/Workflows/Dashboard.md +39 -91
- package/skill/Workflows/Doctor.md +93 -66
- package/skill/Workflows/Evals.md +49 -40
- package/skill/Workflows/Evolve.md +76 -28
- package/skill/Workflows/EvolveBody.md +37 -38
- package/skill/Workflows/Initialize.md +145 -26
- package/skill/Workflows/Orchestrate.md +11 -2
- package/skill/Workflows/Sync.md +23 -0
- package/skill/Workflows/Watch.md +2 -5
- package/skill/agents/diagnosis-analyst.md +163 -0
- package/skill/agents/evolution-reviewer.md +149 -0
- package/skill/agents/integration-guide.md +154 -0
- package/skill/agents/pattern-analyst.md +149 -0
- package/skill/assets/multi-skill-settings.json +1 -1
- package/skill/assets/single-skill-settings.json +1 -1
- package/skill/references/interactive-config.md +39 -0
- package/skill/references/invocation-taxonomy.md +34 -0
- package/skill/references/logs.md +15 -1
- package/skill/references/setup-patterns.md +3 -3
- package/skill/settings_snippet.json +1 -1
- package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
- package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +0 -60
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha upload flush engine.
|
|
3
|
+
*
|
|
4
|
+
* Drains the local upload queue by reading pending items, uploading
|
|
5
|
+
* them via the HTTP client, and updating their status. Implements
|
|
6
|
+
* retry with exponential backoff for transient (5xx/network) failures.
|
|
7
|
+
*
|
|
8
|
+
* Special status handling:
|
|
9
|
+
* - 409 (duplicate push_id) is treated as success
|
|
10
|
+
* - 401/403 (auth failures) are non-retryable with descriptive errors
|
|
11
|
+
* - 4xx (client errors) are not retried
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FlushSummary, QueueOperations } from "../alpha-upload-contract.js";
|
|
15
|
+
import { uploadPushPayload } from "./client.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Options
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Options for the flush engine. */
|
|
22
|
+
export interface FlushOptions {
|
|
23
|
+
/** Maximum number of items to read per flush batch (default: 50). */
|
|
24
|
+
batchSize?: number;
|
|
25
|
+
/** Maximum upload attempts per item before marking permanently failed (default: 5). */
|
|
26
|
+
maxRetries?: number;
|
|
27
|
+
/** When true, log what would be sent without making HTTP calls (default: false). */
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
/** API key for Bearer auth on the cloud endpoint. */
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const DEFAULT_BATCH_SIZE = 50;
|
|
38
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
39
|
+
const INITIAL_BACKOFF_MS = 1_000;
|
|
40
|
+
const MAX_BACKOFF_MS = 16_000;
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Returns true for HTTP status codes that are transient and worth retrying. */
|
|
47
|
+
function isRetryable(status: number): boolean {
|
|
48
|
+
return status === 0 || status === 429 || status >= 500;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns true for auth errors that should not be retried. */
|
|
52
|
+
function isAuthError(status: number): boolean {
|
|
53
|
+
return status === 401 || status === 403;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sleep for the given number of milliseconds. */
|
|
57
|
+
function sleep(ms: number): Promise<void> {
|
|
58
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Calculate exponential backoff with cap. */
|
|
62
|
+
function backoffMs(attempt: number): number {
|
|
63
|
+
const ms = INITIAL_BACKOFF_MS * 2 ** attempt;
|
|
64
|
+
return Math.min(ms, MAX_BACKOFF_MS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extract HTTP status from result. */
|
|
68
|
+
function getStatus(result: Record<string, unknown>): number {
|
|
69
|
+
return (result as { _status?: number })._status ?? (result.success ? 200 : 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Flush engine
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Flush the upload queue -- read pending items, upload them, update status.
|
|
78
|
+
*/
|
|
79
|
+
export async function flushQueue(
|
|
80
|
+
queue: QueueOperations,
|
|
81
|
+
endpoint: string,
|
|
82
|
+
options?: FlushOptions,
|
|
83
|
+
): Promise<FlushSummary> {
|
|
84
|
+
const batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
85
|
+
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
86
|
+
const dryRun = options?.dryRun ?? false;
|
|
87
|
+
const apiKey = options?.apiKey;
|
|
88
|
+
|
|
89
|
+
const summary: FlushSummary = { sent: 0, failed: 0, skipped: 0 };
|
|
90
|
+
|
|
91
|
+
const items = queue.getPending(batchSize);
|
|
92
|
+
|
|
93
|
+
if (items.length === 0) {
|
|
94
|
+
return summary;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const markFailedSafely = (message: string): void => {
|
|
99
|
+
if (!queue.markFailed(item.id, message)) {
|
|
100
|
+
console.error(`[alpha upload] Failed to persist queue failure state for item ${item.id}`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (item.attempts >= maxRetries) {
|
|
105
|
+
markFailedSafely("exhausted retries");
|
|
106
|
+
summary.failed++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
summary.skipped++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let payload: Record<string, unknown>;
|
|
116
|
+
try {
|
|
117
|
+
payload = JSON.parse(item.payload_json) as Record<string, unknown>;
|
|
118
|
+
} catch {
|
|
119
|
+
markFailedSafely("corrupt payload JSON");
|
|
120
|
+
summary.failed++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!queue.markSending(item.id)) {
|
|
125
|
+
console.error(`[alpha upload] Failed to mark queue item ${item.id} as sending`);
|
|
126
|
+
summary.failed++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let succeeded = false;
|
|
131
|
+
const attemptsRemaining = maxRetries - item.attempts;
|
|
132
|
+
|
|
133
|
+
for (let attempt = 0; attempt < attemptsRemaining; attempt++) {
|
|
134
|
+
if (attempt > 0) {
|
|
135
|
+
await sleep(backoffMs(attempt - 1));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = await uploadPushPayload(payload, endpoint, apiKey);
|
|
139
|
+
const status = getStatus(result as unknown as Record<string, unknown>);
|
|
140
|
+
|
|
141
|
+
if (result.success) {
|
|
142
|
+
if (!queue.markSent(item.id)) {
|
|
143
|
+
markFailedSafely("local queue state update failed after successful upload");
|
|
144
|
+
summary.failed++;
|
|
145
|
+
} else {
|
|
146
|
+
summary.sent++;
|
|
147
|
+
}
|
|
148
|
+
succeeded = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 409 Conflict = duplicate push_id, treat as success
|
|
153
|
+
if (status === 409) {
|
|
154
|
+
if (!queue.markSent(item.id)) {
|
|
155
|
+
markFailedSafely("local queue state update failed after duplicate upload");
|
|
156
|
+
summary.failed++;
|
|
157
|
+
} else {
|
|
158
|
+
summary.sent++;
|
|
159
|
+
}
|
|
160
|
+
succeeded = true;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Auth errors are non-retryable
|
|
165
|
+
if (isAuthError(status)) {
|
|
166
|
+
const authMessage =
|
|
167
|
+
status === 401
|
|
168
|
+
? "Authentication failed: invalid or missing API key. Run 'selftune init --alpha --alpha-email <email>' to re-authenticate via browser."
|
|
169
|
+
: "Authorization denied: your API key does not have permission to upload. Run 'selftune doctor' to verify enrollment and cloud link, then re-run 'selftune init --alpha --alpha-email <email> --force' to re-authenticate.";
|
|
170
|
+
markFailedSafely(authMessage);
|
|
171
|
+
summary.failed++;
|
|
172
|
+
succeeded = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!isRetryable(status)) {
|
|
177
|
+
markFailedSafely(result.errors[0] ?? `Upload failed with HTTP ${status}`);
|
|
178
|
+
summary.failed++;
|
|
179
|
+
succeeded = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!succeeded) {
|
|
185
|
+
markFailedSafely("exhausted retries");
|
|
186
|
+
summary.failed++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha upload orchestration module.
|
|
3
|
+
*
|
|
4
|
+
* Coordinates the full upload cycle:
|
|
5
|
+
* 1. Stage canonical records from JSONL + evolution evidence into staging table
|
|
6
|
+
* 2. Read new staged records since watermark via single cursor
|
|
7
|
+
* 3. Build a V2 canonical push payload
|
|
8
|
+
* 4. Enqueue it in the local upload queue
|
|
9
|
+
* 5. Flush the queue to POST /api/v1/push
|
|
10
|
+
*
|
|
11
|
+
* Guards:
|
|
12
|
+
* - Only runs when alpha enrolled (config.alpha?.enrolled === true)
|
|
13
|
+
* - Fail-open: never throws, returns empty summary on errors
|
|
14
|
+
* - Reads endpoint from config or SELFTUNE_ALPHA_ENDPOINT env var
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
QueueItem as ContractQueueItem,
|
|
21
|
+
FlushSummary,
|
|
22
|
+
QueueOperations,
|
|
23
|
+
} from "../alpha-upload-contract.js";
|
|
24
|
+
import { buildV2PushPayload } from "./build-payloads.js";
|
|
25
|
+
import { flushQueue } from "./flush.js";
|
|
26
|
+
import {
|
|
27
|
+
enqueueUpload,
|
|
28
|
+
getPendingUploads,
|
|
29
|
+
markFailed,
|
|
30
|
+
markSending,
|
|
31
|
+
markSent,
|
|
32
|
+
readWatermark,
|
|
33
|
+
writeWatermark,
|
|
34
|
+
} from "./queue.js";
|
|
35
|
+
import { stageCanonicalRecords } from "./stage-canonical.js";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Constants
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const DEFAULT_ENDPOINT = "https://api.selftune.dev/api/v1/push";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Types
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export interface PrepareResult {
|
|
48
|
+
enqueued: number;
|
|
49
|
+
types: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UploadCycleOptions {
|
|
53
|
+
enrolled: boolean;
|
|
54
|
+
userId?: string;
|
|
55
|
+
agentType?: string;
|
|
56
|
+
selftuneVersion?: string;
|
|
57
|
+
endpoint?: string;
|
|
58
|
+
dryRun?: boolean;
|
|
59
|
+
apiKey?: string;
|
|
60
|
+
/** Override canonical log path (for testing). */
|
|
61
|
+
canonicalLogPath?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UploadCycleSummary {
|
|
65
|
+
enrolled: boolean;
|
|
66
|
+
prepared: number;
|
|
67
|
+
sent: number;
|
|
68
|
+
failed: number;
|
|
69
|
+
skipped: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// prepareUploads -- stage, build V2 payload, enqueue
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stage canonical records, read new staged rows since watermark,
|
|
78
|
+
* build a single V2 push payload, and enqueue it. Never throws.
|
|
79
|
+
*/
|
|
80
|
+
export function prepareUploads(
|
|
81
|
+
db: Database,
|
|
82
|
+
_userId: string,
|
|
83
|
+
_agentType: string,
|
|
84
|
+
_selftuneVersion: string,
|
|
85
|
+
canonicalLogPath?: string,
|
|
86
|
+
): PrepareResult {
|
|
87
|
+
const result: PrepareResult = { enqueued: 0, types: [] };
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Step 1: Stage canonical records from JSONL + evolution evidence
|
|
91
|
+
stageCanonicalRecords(db, canonicalLogPath);
|
|
92
|
+
|
|
93
|
+
// Step 2: Read watermark (single cursor for all record types)
|
|
94
|
+
const afterSeq = readWatermark(db, "canonical") ?? undefined;
|
|
95
|
+
|
|
96
|
+
// Step 3: Build payload from staging table
|
|
97
|
+
const build = buildV2PushPayload(db, afterSeq);
|
|
98
|
+
|
|
99
|
+
if (!build) return result;
|
|
100
|
+
|
|
101
|
+
// Step 4: Enqueue the payload + advance watermark atomically
|
|
102
|
+
const tx = db.transaction(() => {
|
|
103
|
+
const ok = enqueueUpload(db, "push", JSON.stringify(build.payload));
|
|
104
|
+
if (!ok) {
|
|
105
|
+
throw new Error("enqueueUpload failed");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!writeWatermark(db, "canonical", build.lastSeq)) {
|
|
109
|
+
throw new Error("writeWatermark failed");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
result.enqueued = 1;
|
|
113
|
+
result.types.push("canonical");
|
|
114
|
+
});
|
|
115
|
+
tx();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
118
|
+
console.error("[alpha-upload] prepareUploads failed:", err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// runUploadCycle -- the full cycle: prepare -> flush -> return summary
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a full upload cycle: stage + read new data, enqueue it, flush to remote.
|
|
131
|
+
* Guards on enrollment -- returns empty summary if not enrolled.
|
|
132
|
+
* Never throws.
|
|
133
|
+
*/
|
|
134
|
+
export async function runUploadCycle(
|
|
135
|
+
db: Database,
|
|
136
|
+
options: UploadCycleOptions,
|
|
137
|
+
): Promise<UploadCycleSummary> {
|
|
138
|
+
const emptySummary: UploadCycleSummary = {
|
|
139
|
+
enrolled: options.enrolled,
|
|
140
|
+
prepared: 0,
|
|
141
|
+
sent: 0,
|
|
142
|
+
failed: 0,
|
|
143
|
+
skipped: 0,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Guard: must be enrolled
|
|
147
|
+
if (!options.enrolled) {
|
|
148
|
+
return emptySummary;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const userId = options.userId ?? "unknown";
|
|
153
|
+
const agentType = options.agentType ?? "unknown";
|
|
154
|
+
const selftuneVersion = options.selftuneVersion ?? "0.0.0";
|
|
155
|
+
const endpoint = process.env.SELFTUNE_ALPHA_ENDPOINT ?? options.endpoint ?? DEFAULT_ENDPOINT;
|
|
156
|
+
const dryRun = options.dryRun ?? false;
|
|
157
|
+
const apiKey = options.apiKey;
|
|
158
|
+
|
|
159
|
+
// Step 1: Prepare -- stage, build V2 payload, enqueue
|
|
160
|
+
const prepared = prepareUploads(
|
|
161
|
+
db,
|
|
162
|
+
userId,
|
|
163
|
+
agentType,
|
|
164
|
+
selftuneVersion,
|
|
165
|
+
options.canonicalLogPath,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Step 2: Flush -- drain the queue to the remote endpoint
|
|
169
|
+
const queueOps: QueueOperations = {
|
|
170
|
+
getPending: (limit: number) => getPendingUploads(db, limit) as ContractQueueItem[],
|
|
171
|
+
markSending: (id: number) => markSending(db, [id]),
|
|
172
|
+
markSent: (id: number) => markSent(db, [id]),
|
|
173
|
+
markFailed: (id: number, error?: string) => markFailed(db, id, error ?? "unknown"),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const flush: FlushSummary = await flushQueue(queueOps, endpoint, {
|
|
177
|
+
dryRun,
|
|
178
|
+
apiKey,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
enrolled: true,
|
|
183
|
+
prepared: prepared.enqueued,
|
|
184
|
+
sent: flush.sent,
|
|
185
|
+
failed: flush.failed,
|
|
186
|
+
skipped: flush.skipped,
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
190
|
+
console.error("[alpha-upload] runUploadCycle failed:", err);
|
|
191
|
+
}
|
|
192
|
+
return emptySummary;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha upload queue — local queue and watermark storage layer.
|
|
3
|
+
*
|
|
4
|
+
* Queues payload items for upload to the alpha remote endpoint.
|
|
5
|
+
* No HTTP code — this module only manages the SQLite queue state.
|
|
6
|
+
*
|
|
7
|
+
* All public functions follow the fail-open pattern from direct-write.ts:
|
|
8
|
+
* they catch errors internally and return boolean success / safe defaults.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
|
|
13
|
+
// -- Types --------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface QueueItem {
|
|
16
|
+
id: number;
|
|
17
|
+
payload_type: string;
|
|
18
|
+
payload_json: string;
|
|
19
|
+
status: string;
|
|
20
|
+
attempts: number;
|
|
21
|
+
created_at: string;
|
|
22
|
+
updated_at: string;
|
|
23
|
+
last_error: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QueueStats {
|
|
27
|
+
pending: number;
|
|
28
|
+
sending: number;
|
|
29
|
+
sent: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -- Queue operations ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Insert a new pending item into the upload queue.
|
|
37
|
+
* Returns true on success, false on failure (fail-open).
|
|
38
|
+
*/
|
|
39
|
+
export function enqueueUpload(db: Database, payloadType: string, payloadJson: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const now = new Date().toISOString();
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO upload_queue (payload_type, payload_json, status, attempts, created_at, updated_at)
|
|
44
|
+
VALUES (?, ?, 'pending', 0, ?, ?)`,
|
|
45
|
+
[payloadType, payloadJson, now, now],
|
|
46
|
+
);
|
|
47
|
+
return true;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
50
|
+
console.error("[alpha-upload/queue] enqueueUpload failed:", err);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get pending upload items, oldest first.
|
|
58
|
+
* Default limit is 50.
|
|
59
|
+
*/
|
|
60
|
+
export function getPendingUploads(db: Database, limit = 50): QueueItem[] {
|
|
61
|
+
try {
|
|
62
|
+
return db
|
|
63
|
+
.query(
|
|
64
|
+
`SELECT id, payload_type, payload_json, status, attempts, created_at, updated_at, last_error
|
|
65
|
+
FROM upload_queue
|
|
66
|
+
WHERE status = 'pending'
|
|
67
|
+
ORDER BY id ASC
|
|
68
|
+
LIMIT ?`,
|
|
69
|
+
)
|
|
70
|
+
.all(limit) as QueueItem[];
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
73
|
+
console.error("[alpha-upload/queue] getPendingUploads failed:", err);
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Transition pending items to sending status.
|
|
81
|
+
* Only transitions items that are currently 'pending'.
|
|
82
|
+
*/
|
|
83
|
+
export function markSending(db: Database, ids: number[]): boolean {
|
|
84
|
+
if (ids.length === 0) return true;
|
|
85
|
+
try {
|
|
86
|
+
const now = new Date().toISOString();
|
|
87
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
88
|
+
db.run(
|
|
89
|
+
`UPDATE upload_queue
|
|
90
|
+
SET status = 'sending', updated_at = ?
|
|
91
|
+
WHERE id IN (${placeholders}) AND status = 'pending'`,
|
|
92
|
+
[now, ...ids],
|
|
93
|
+
);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
97
|
+
console.error("[alpha-upload/queue] markSending failed:", err);
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Transition sending items to sent status.
|
|
105
|
+
* Also updates the watermark per payload_type to the max id in the batch.
|
|
106
|
+
*/
|
|
107
|
+
export function markSent(db: Database, ids: number[]): boolean {
|
|
108
|
+
if (ids.length === 0) return true;
|
|
109
|
+
try {
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
112
|
+
|
|
113
|
+
db.run("BEGIN TRANSACTION");
|
|
114
|
+
try {
|
|
115
|
+
const sendingRows = db
|
|
116
|
+
.query(
|
|
117
|
+
`SELECT id, payload_type
|
|
118
|
+
FROM upload_queue
|
|
119
|
+
WHERE id IN (${placeholders}) AND status = 'sending'`,
|
|
120
|
+
)
|
|
121
|
+
.all(...ids) as Array<{ id: number; payload_type: string }>;
|
|
122
|
+
|
|
123
|
+
// Mark items as sent
|
|
124
|
+
db.run(
|
|
125
|
+
`UPDATE upload_queue
|
|
126
|
+
SET status = 'sent', updated_at = ?
|
|
127
|
+
WHERE id IN (${placeholders}) AND status = 'sending'`,
|
|
128
|
+
[now, ...ids],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Update watermarks only for rows that actually transitioned from "sending".
|
|
132
|
+
const maxByType = new Map<string, number>();
|
|
133
|
+
for (const row of sendingRows) {
|
|
134
|
+
const current = maxByType.get(row.payload_type) ?? 0;
|
|
135
|
+
if (row.id > current) {
|
|
136
|
+
maxByType.set(row.payload_type, row.id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [payloadType, maxId] of maxByType.entries()) {
|
|
141
|
+
db.run(
|
|
142
|
+
`INSERT INTO upload_watermarks (payload_type, last_uploaded_id, updated_at)
|
|
143
|
+
VALUES (?, ?, ?)
|
|
144
|
+
ON CONFLICT(payload_type) DO UPDATE SET
|
|
145
|
+
last_uploaded_id = excluded.last_uploaded_id,
|
|
146
|
+
updated_at = excluded.updated_at`,
|
|
147
|
+
[payloadType, maxId, now],
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
db.run("COMMIT");
|
|
152
|
+
} catch (err) {
|
|
153
|
+
db.run("ROLLBACK");
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
159
|
+
console.error("[alpha-upload/queue] markSent failed:", err);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Transition a sending item to failed status.
|
|
167
|
+
* Increments the attempts counter and records the error message.
|
|
168
|
+
*/
|
|
169
|
+
export function markFailed(db: Database, id: number, error: string): boolean {
|
|
170
|
+
try {
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
db.run(
|
|
173
|
+
`UPDATE upload_queue
|
|
174
|
+
SET status = 'failed', attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
175
|
+
WHERE id = ? AND status = 'sending'`,
|
|
176
|
+
[error, now, id],
|
|
177
|
+
);
|
|
178
|
+
return true;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
181
|
+
console.error("[alpha-upload/queue] markFailed failed:", err);
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get counts of items by status.
|
|
189
|
+
*/
|
|
190
|
+
export function getQueueStats(db: Database): QueueStats {
|
|
191
|
+
try {
|
|
192
|
+
const row = db
|
|
193
|
+
.query(
|
|
194
|
+
`SELECT
|
|
195
|
+
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as pending,
|
|
196
|
+
COALESCE(SUM(CASE WHEN status = 'sending' THEN 1 ELSE 0 END), 0) as sending,
|
|
197
|
+
COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0) as sent,
|
|
198
|
+
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as failed
|
|
199
|
+
FROM upload_queue`,
|
|
200
|
+
)
|
|
201
|
+
.get() as QueueStats;
|
|
202
|
+
return row;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
205
|
+
console.error("[alpha-upload/queue] getQueueStats failed:", err);
|
|
206
|
+
}
|
|
207
|
+
return { pending: 0, sending: 0, sent: 0, failed: 0 };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -- Watermark operations -----------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read the last uploaded ID for a given payload type.
|
|
215
|
+
* Returns null if no watermark exists.
|
|
216
|
+
*/
|
|
217
|
+
export function readWatermark(db: Database, payloadType: string): number | null {
|
|
218
|
+
try {
|
|
219
|
+
const row = db
|
|
220
|
+
.query("SELECT last_uploaded_id FROM upload_watermarks WHERE payload_type = ?")
|
|
221
|
+
.get(payloadType) as { last_uploaded_id: number } | null;
|
|
222
|
+
return row?.last_uploaded_id ?? null;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
225
|
+
console.error("[alpha-upload/queue] readWatermark failed:", err);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Upsert the watermark for a given payload type.
|
|
233
|
+
*/
|
|
234
|
+
export function writeWatermark(db: Database, payloadType: string, lastId: number): boolean {
|
|
235
|
+
try {
|
|
236
|
+
const now = new Date().toISOString();
|
|
237
|
+
db.run(
|
|
238
|
+
`INSERT INTO upload_watermarks (payload_type, last_uploaded_id, updated_at)
|
|
239
|
+
VALUES (?, ?, ?)
|
|
240
|
+
ON CONFLICT(payload_type) DO UPDATE SET
|
|
241
|
+
last_uploaded_id = excluded.last_uploaded_id,
|
|
242
|
+
updated_at = excluded.updated_at`,
|
|
243
|
+
[payloadType, lastId, now],
|
|
244
|
+
);
|
|
245
|
+
return true;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (process.env.DEBUG || process.env.NODE_ENV === "development") {
|
|
248
|
+
console.error("[alpha-upload/queue] writeWatermark failed:", err);
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|