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.
Files changed (119) hide show
  1. package/README.md +1 -0
  2. package/apps/local-dashboard/dist/assets/index-Bs3Y4ixf.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-C4UYGWKr.js +15 -0
  4. package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
  5. package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
  6. package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
  7. package/apps/local-dashboard/dist/index.html +5 -5
  8. package/cli/selftune/activation-rules.ts +57 -18
  9. package/cli/selftune/agent-guidance.ts +96 -0
  10. package/cli/selftune/alpha-identity.ts +156 -0
  11. package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
  12. package/cli/selftune/alpha-upload/client.ts +113 -0
  13. package/cli/selftune/alpha-upload/flush.ts +191 -0
  14. package/cli/selftune/alpha-upload/index.ts +194 -0
  15. package/cli/selftune/alpha-upload/queue.ts +252 -0
  16. package/cli/selftune/alpha-upload/stage-canonical.ts +251 -0
  17. package/cli/selftune/alpha-upload-contract.ts +52 -0
  18. package/cli/selftune/auth/device-code.ts +110 -0
  19. package/cli/selftune/auto-update.ts +130 -0
  20. package/cli/selftune/badge/badge.ts +19 -9
  21. package/cli/selftune/canonical-export.ts +16 -3
  22. package/cli/selftune/constants.ts +28 -8
  23. package/cli/selftune/contribute/bundle.ts +33 -5
  24. package/cli/selftune/dashboard-contract.ts +32 -1
  25. package/cli/selftune/dashboard-server.ts +215 -693
  26. package/cli/selftune/dashboard.ts +1 -1
  27. package/cli/selftune/eval/baseline.ts +11 -7
  28. package/cli/selftune/eval/hooks-to-evals.ts +39 -15
  29. package/cli/selftune/eval/synthetic-evals.ts +54 -1
  30. package/cli/selftune/evolution/audit.ts +24 -19
  31. package/cli/selftune/evolution/constitutional.ts +176 -0
  32. package/cli/selftune/evolution/evidence.ts +18 -13
  33. package/cli/selftune/evolution/evolve-body.ts +104 -7
  34. package/cli/selftune/evolution/evolve.ts +195 -22
  35. package/cli/selftune/evolution/propose-body.ts +18 -1
  36. package/cli/selftune/evolution/propose-description.ts +27 -2
  37. package/cli/selftune/evolution/rollback.ts +11 -15
  38. package/cli/selftune/export.ts +84 -0
  39. package/cli/selftune/grading/auto-grade.ts +14 -4
  40. package/cli/selftune/grading/grade-session.ts +17 -6
  41. package/cli/selftune/hooks/auto-activate.ts +5 -0
  42. package/cli/selftune/hooks/evolution-guard.ts +25 -11
  43. package/cli/selftune/hooks/prompt-log.ts +23 -9
  44. package/cli/selftune/hooks/session-stop.ts +78 -15
  45. package/cli/selftune/hooks/skill-eval.ts +189 -10
  46. package/cli/selftune/index.ts +274 -2
  47. package/cli/selftune/ingestors/claude-replay.ts +48 -21
  48. package/cli/selftune/init.ts +260 -49
  49. package/cli/selftune/last.ts +7 -7
  50. package/cli/selftune/localdb/db.ts +90 -10
  51. package/cli/selftune/localdb/direct-write.ts +573 -0
  52. package/cli/selftune/localdb/materialize.ts +296 -42
  53. package/cli/selftune/localdb/queries.ts +482 -32
  54. package/cli/selftune/localdb/schema.ts +153 -1
  55. package/cli/selftune/monitoring/watch.ts +27 -8
  56. package/cli/selftune/normalization.ts +88 -15
  57. package/cli/selftune/observability.ts +257 -5
  58. package/cli/selftune/orchestrate.ts +176 -53
  59. package/cli/selftune/quickstart.ts +34 -10
  60. package/cli/selftune/repair/skill-usage.ts +15 -2
  61. package/cli/selftune/routes/actions.ts +77 -0
  62. package/cli/selftune/routes/badge.ts +66 -0
  63. package/cli/selftune/routes/doctor.ts +12 -0
  64. package/cli/selftune/routes/index.ts +14 -0
  65. package/cli/selftune/routes/orchestrate-runs.ts +13 -0
  66. package/cli/selftune/routes/overview.ts +14 -0
  67. package/cli/selftune/routes/report.ts +293 -0
  68. package/cli/selftune/routes/skill-report.ts +230 -0
  69. package/cli/selftune/status.ts +203 -7
  70. package/cli/selftune/sync.ts +14 -1
  71. package/cli/selftune/types.ts +52 -2
  72. package/cli/selftune/utils/jsonl.ts +58 -1
  73. package/cli/selftune/utils/selftune-meta.ts +38 -0
  74. package/cli/selftune/utils/skill-log.ts +30 -4
  75. package/cli/selftune/utils/transcript.ts +15 -0
  76. package/cli/selftune/workflows/workflows.ts +7 -6
  77. package/package.json +11 -6
  78. package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
  79. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +1 -0
  81. package/packages/telemetry-contract/fixtures/index.ts +4 -0
  82. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
  83. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
  84. package/packages/telemetry-contract/package.json +6 -1
  85. package/packages/telemetry-contract/src/schemas.ts +196 -0
  86. package/packages/telemetry-contract/src/types.ts +3 -1
  87. package/packages/telemetry-contract/src/validators.ts +3 -1
  88. package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
  89. package/packages/ui/package.json +4 -0
  90. package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
  91. package/packages/ui/src/components/section-cards.tsx +31 -14
  92. package/packages/ui/src/types.ts +1 -0
  93. package/skill/SKILL.md +214 -174
  94. package/skill/Workflows/AlphaUpload.md +45 -0
  95. package/skill/Workflows/Baseline.md +18 -12
  96. package/skill/Workflows/Composability.md +3 -3
  97. package/skill/Workflows/Dashboard.md +39 -91
  98. package/skill/Workflows/Doctor.md +93 -66
  99. package/skill/Workflows/Evals.md +49 -40
  100. package/skill/Workflows/Evolve.md +76 -28
  101. package/skill/Workflows/EvolveBody.md +37 -38
  102. package/skill/Workflows/Initialize.md +145 -26
  103. package/skill/Workflows/Orchestrate.md +11 -2
  104. package/skill/Workflows/Sync.md +23 -0
  105. package/skill/Workflows/Watch.md +2 -5
  106. package/skill/agents/diagnosis-analyst.md +163 -0
  107. package/skill/agents/evolution-reviewer.md +149 -0
  108. package/skill/agents/integration-guide.md +154 -0
  109. package/skill/agents/pattern-analyst.md +149 -0
  110. package/skill/assets/multi-skill-settings.json +1 -1
  111. package/skill/assets/single-skill-settings.json +1 -1
  112. package/skill/references/interactive-config.md +39 -0
  113. package/skill/references/invocation-taxonomy.md +34 -0
  114. package/skill/references/logs.md +15 -1
  115. package/skill/references/setup-patterns.md +3 -3
  116. package/skill/settings_snippet.json +1 -1
  117. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
  118. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
  119. 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
+ }