rowbound 1.0.2
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/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/adapters/adapter.d.ts +1 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
- package/dist/adapters/sheets/sheets-adapter.js +531 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +397 -0
- package/dist/cli/env.d.ts +3 -0
- package/dist/cli/env.js +103 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/init.d.ts +10 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +212 -0
- package/dist/cli/runs.d.ts +2 -0
- package/dist/cli/runs.js +108 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +108 -0
- package/dist/cli/sync.d.ts +2 -0
- package/dist/cli/sync.js +84 -0
- package/dist/cli/watch.d.ts +2 -0
- package/dist/cli/watch.js +348 -0
- package/dist/core/condition.d.ts +25 -0
- package/dist/core/condition.js +66 -0
- package/dist/core/defaults.d.ts +3 -0
- package/dist/core/defaults.js +7 -0
- package/dist/core/engine.d.ts +50 -0
- package/dist/core/engine.js +234 -0
- package/dist/core/env.d.ts +13 -0
- package/dist/core/env.js +72 -0
- package/dist/core/exec.d.ts +24 -0
- package/dist/core/exec.js +134 -0
- package/dist/core/extractor.d.ts +10 -0
- package/dist/core/extractor.js +33 -0
- package/dist/core/http-client.d.ts +32 -0
- package/dist/core/http-client.js +161 -0
- package/dist/core/rate-limiter.d.ts +25 -0
- package/dist/core/rate-limiter.js +64 -0
- package/dist/core/reconcile.d.ts +24 -0
- package/dist/core/reconcile.js +192 -0
- package/dist/core/run-format.d.ts +39 -0
- package/dist/core/run-format.js +201 -0
- package/dist/core/run-state.d.ts +64 -0
- package/dist/core/run-state.js +141 -0
- package/dist/core/run-tracker.d.ts +15 -0
- package/dist/core/run-tracker.js +57 -0
- package/dist/core/safe-compare.d.ts +8 -0
- package/dist/core/safe-compare.js +19 -0
- package/dist/core/shell-escape.d.ts +7 -0
- package/dist/core/shell-escape.js +9 -0
- package/dist/core/tab-resolver.d.ts +17 -0
- package/dist/core/tab-resolver.js +44 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.js +82 -0
- package/dist/core/types.d.ts +105 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-guard.d.ts +21 -0
- package/dist/core/url-guard.js +184 -0
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.js +261 -0
- package/dist/core/waterfall.d.ts +26 -0
- package/dist/core/waterfall.js +55 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +943 -0
- package/package.json +67 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { validateUrl } from "./url-guard.js";
|
|
2
|
+
/** Thrown when onError config specifies "stop_provider" */
|
|
3
|
+
export class StopProviderError extends Error {
|
|
4
|
+
constructor(message = "Provider stopped due to error") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "StopProviderError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Check if a status code is retryable (429 or 5xx).
|
|
11
|
+
*/
|
|
12
|
+
function isRetryable(status) {
|
|
13
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the onError action for a given status code.
|
|
17
|
+
* Checks the specific status first, then falls back to "default".
|
|
18
|
+
*/
|
|
19
|
+
function resolveErrorAction(onError, status) {
|
|
20
|
+
if (!onError)
|
|
21
|
+
return undefined;
|
|
22
|
+
const statusKey = String(status);
|
|
23
|
+
if (statusKey in onError) {
|
|
24
|
+
return onError[statusKey];
|
|
25
|
+
}
|
|
26
|
+
if ("default" in onError) {
|
|
27
|
+
return onError.default;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Apply the resolved error action, returning an HttpResponse or throwing.
|
|
33
|
+
*/
|
|
34
|
+
function applyErrorAction(action, status) {
|
|
35
|
+
if (action === undefined) {
|
|
36
|
+
// No error handler — throw a generic error
|
|
37
|
+
throw new Error(`HTTP request failed with status ${status}`);
|
|
38
|
+
}
|
|
39
|
+
if (action === "skip") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (action === "stop_provider") {
|
|
43
|
+
throw new StopProviderError(`Provider stopped: HTTP ${status}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof action === "object" && "write" in action) {
|
|
46
|
+
return { status, data: action.write };
|
|
47
|
+
}
|
|
48
|
+
// Unknown action — treat as skip
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Make an HTTP request with retry, rate limiting, and structured error handling.
|
|
53
|
+
*
|
|
54
|
+
* - Acquires a rate limiter token before each request attempt
|
|
55
|
+
* - Retries on 429/5xx with exponential backoff
|
|
56
|
+
* - Applies onError config for non-retryable errors or exhausted retries
|
|
57
|
+
* - Respects AbortSignal for cancellation
|
|
58
|
+
*/
|
|
59
|
+
export async function httpRequest(options) {
|
|
60
|
+
const { method, url, headers, body, retryAttempts = 0, retryBackoff, onError, rateLimiter, signal, } = options;
|
|
61
|
+
// Validate URL to prevent SSRF
|
|
62
|
+
validateUrl(url);
|
|
63
|
+
const maxAttempts = retryAttempts + 1;
|
|
64
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
65
|
+
// Check abort before each attempt
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
throw new Error("Request aborted");
|
|
68
|
+
}
|
|
69
|
+
// Acquire rate limiter token
|
|
70
|
+
if (rateLimiter) {
|
|
71
|
+
await rateLimiter.acquire(signal);
|
|
72
|
+
}
|
|
73
|
+
let finalHeaders = headers;
|
|
74
|
+
if (body !== undefined) {
|
|
75
|
+
const hasContentType = headers &&
|
|
76
|
+
Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
|
|
77
|
+
if (!hasContentType) {
|
|
78
|
+
finalHeaders = { ...headers, "Content-Type": "application/json" };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(url, {
|
|
84
|
+
method,
|
|
85
|
+
headers: finalHeaders,
|
|
86
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
87
|
+
signal,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (_err) {
|
|
91
|
+
// Network error or abort
|
|
92
|
+
if (signal?.aborted) {
|
|
93
|
+
throw new Error("Request aborted");
|
|
94
|
+
}
|
|
95
|
+
if (attempt < maxAttempts - 1) {
|
|
96
|
+
await backoff(attempt, retryBackoff, signal);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const action = resolveErrorAction(onError, 0);
|
|
100
|
+
return applyErrorAction(action, 0);
|
|
101
|
+
}
|
|
102
|
+
// Success
|
|
103
|
+
if (response.ok) {
|
|
104
|
+
const data = await parseResponseBody(response);
|
|
105
|
+
return { status: response.status, data };
|
|
106
|
+
}
|
|
107
|
+
// Retryable error
|
|
108
|
+
if (isRetryable(response.status) && attempt < maxAttempts - 1) {
|
|
109
|
+
await backoff(attempt, retryBackoff, signal);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Non-retryable error, or retries exhausted
|
|
113
|
+
const action = resolveErrorAction(onError, response.status);
|
|
114
|
+
return applyErrorAction(action, response.status);
|
|
115
|
+
}
|
|
116
|
+
// Should not be reached, but handle gracefully
|
|
117
|
+
throw new Error("HTTP request failed: no attempts made");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Backoff delay between retries. Respects AbortSignal to allow early exit.
|
|
121
|
+
*
|
|
122
|
+
* Strategies:
|
|
123
|
+
* - "exponential" (default): 2^attempt * 100ms (100, 200, 400, 800, ...)
|
|
124
|
+
* - "linear": (attempt + 1) * 200ms (200, 400, 600, ...)
|
|
125
|
+
* - "fixed": constant 1000ms
|
|
126
|
+
*/
|
|
127
|
+
async function backoff(attempt, strategy, signal) {
|
|
128
|
+
let delayMs;
|
|
129
|
+
switch (strategy) {
|
|
130
|
+
case "linear":
|
|
131
|
+
delayMs = (attempt + 1) * 200;
|
|
132
|
+
break;
|
|
133
|
+
case "fixed":
|
|
134
|
+
delayMs = 1000;
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
delayMs = 2 ** attempt * 100;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
if (signal?.aborted) {
|
|
142
|
+
resolve();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const timer = setTimeout(resolve, delayMs);
|
|
146
|
+
signal?.addEventListener("abort", () => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
resolve();
|
|
149
|
+
}, { once: true });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Parse response body as JSON, falling back to text.
|
|
154
|
+
*/
|
|
155
|
+
async function parseResponseBody(response) {
|
|
156
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
157
|
+
if (contentType.includes("json")) {
|
|
158
|
+
return response.json();
|
|
159
|
+
}
|
|
160
|
+
return response.text();
|
|
161
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token bucket rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* Refills tokens based on elapsed time, with max capacity equal to
|
|
5
|
+
* tokensPerSecond. Used globally across all HTTP requests.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RateLimiter {
|
|
8
|
+
private readonly tokensPerSecond;
|
|
9
|
+
private tokens;
|
|
10
|
+
private readonly maxTokens;
|
|
11
|
+
private lastRefill;
|
|
12
|
+
private queue;
|
|
13
|
+
constructor(tokensPerSecond: number);
|
|
14
|
+
/**
|
|
15
|
+
* Acquire a single token. Resolves immediately if a token is available,
|
|
16
|
+
* otherwise waits until one is refilled. Respects AbortSignal for early exit.
|
|
17
|
+
*
|
|
18
|
+
* Serialized via a promise chain to prevent race conditions when
|
|
19
|
+
* multiple callers invoke acquire() concurrently.
|
|
20
|
+
*/
|
|
21
|
+
acquire(signal?: AbortSignal): Promise<void>;
|
|
22
|
+
private _acquire;
|
|
23
|
+
private refill;
|
|
24
|
+
private sleep;
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token bucket rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* Refills tokens based on elapsed time, with max capacity equal to
|
|
5
|
+
* tokensPerSecond. Used globally across all HTTP requests.
|
|
6
|
+
*/
|
|
7
|
+
export class RateLimiter {
|
|
8
|
+
tokensPerSecond;
|
|
9
|
+
tokens;
|
|
10
|
+
maxTokens;
|
|
11
|
+
lastRefill;
|
|
12
|
+
queue = Promise.resolve();
|
|
13
|
+
constructor(tokensPerSecond) {
|
|
14
|
+
this.tokensPerSecond = tokensPerSecond;
|
|
15
|
+
this.maxTokens = tokensPerSecond;
|
|
16
|
+
this.tokens = tokensPerSecond;
|
|
17
|
+
this.lastRefill = Date.now();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Acquire a single token. Resolves immediately if a token is available,
|
|
21
|
+
* otherwise waits until one is refilled. Respects AbortSignal for early exit.
|
|
22
|
+
*
|
|
23
|
+
* Serialized via a promise chain to prevent race conditions when
|
|
24
|
+
* multiple callers invoke acquire() concurrently.
|
|
25
|
+
*/
|
|
26
|
+
async acquire(signal) {
|
|
27
|
+
this.queue = this.queue.then(() => this._acquire(signal));
|
|
28
|
+
return this.queue;
|
|
29
|
+
}
|
|
30
|
+
async _acquire(signal) {
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
return;
|
|
33
|
+
this.refill();
|
|
34
|
+
if (this.tokens >= 1) {
|
|
35
|
+
this.tokens -= 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// Calculate wait time until at least one token is available
|
|
39
|
+
const deficit = 1 - this.tokens;
|
|
40
|
+
const waitMs = (deficit / this.tokensPerSecond) * 1000;
|
|
41
|
+
await this.sleep(waitMs, signal);
|
|
42
|
+
this.refill();
|
|
43
|
+
this.tokens -= 1;
|
|
44
|
+
}
|
|
45
|
+
refill() {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const elapsed = (now - this.lastRefill) / 1000;
|
|
48
|
+
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.tokensPerSecond);
|
|
49
|
+
this.lastRefill = now;
|
|
50
|
+
}
|
|
51
|
+
sleep(ms, signal) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
if (signal?.aborted) {
|
|
54
|
+
resolve();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const timer = setTimeout(resolve, ms);
|
|
58
|
+
signal?.addEventListener("abort", () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve();
|
|
61
|
+
}, { once: true });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SheetsAdapter } from "../adapters/sheets/sheets-adapter.js";
|
|
2
|
+
import type { PipelineConfig, SheetRef, TabConfig } from "./types.js";
|
|
3
|
+
export interface ReconcileResult {
|
|
4
|
+
/** Updated config — always v2 format */
|
|
5
|
+
config: PipelineConfig;
|
|
6
|
+
/** The GID of the tab being operated on */
|
|
7
|
+
tabGid: string;
|
|
8
|
+
/** The specific tab's config (convenience) */
|
|
9
|
+
tabConfig: TabConfig;
|
|
10
|
+
/** User-facing messages about detected changes */
|
|
11
|
+
messages: string[];
|
|
12
|
+
/** Whether the config was modified and needs re-saving */
|
|
13
|
+
configChanged: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reconcile a pipeline config with the current sheet state.
|
|
17
|
+
*
|
|
18
|
+
* This function handles:
|
|
19
|
+
* 1. v1 → v2 migration (wraps top-level actions/columns under the resolved GID)
|
|
20
|
+
* 2. Tab name reconciliation (detects renamed tabs by GID)
|
|
21
|
+
* 3. Column reconciliation for the target tab (named ranges, renames, new columns)
|
|
22
|
+
* 4. Action target migration from column names to IDs
|
|
23
|
+
*/
|
|
24
|
+
export declare function reconcile(adapter: SheetsAdapter, ref: SheetRef, config: PipelineConfig): Promise<ReconcileResult>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Reconcile a pipeline config with the current sheet state.
|
|
4
|
+
*
|
|
5
|
+
* This function handles:
|
|
6
|
+
* 1. v1 → v2 migration (wraps top-level actions/columns under the resolved GID)
|
|
7
|
+
* 2. Tab name reconciliation (detects renamed tabs by GID)
|
|
8
|
+
* 3. Column reconciliation for the target tab (named ranges, renames, new columns)
|
|
9
|
+
* 4. Action target migration from column names to IDs
|
|
10
|
+
*/
|
|
11
|
+
export async function reconcile(adapter, ref, config) {
|
|
12
|
+
const messages = [];
|
|
13
|
+
let configChanged = false;
|
|
14
|
+
// --- Action 1: Get all tabs from the spreadsheet ---
|
|
15
|
+
const sheets = await adapter.listSheets(ref.spreadsheetId);
|
|
16
|
+
const targetName = ref.sheetName || "Sheet1";
|
|
17
|
+
const targetSheet = sheets.find((s) => s.name === targetName);
|
|
18
|
+
if (!targetSheet) {
|
|
19
|
+
throw new Error(`Tab "${targetName}" not found in spreadsheet ${ref.spreadsheetId}`);
|
|
20
|
+
}
|
|
21
|
+
const tabGid = String(targetSheet.gid);
|
|
22
|
+
// --- Action 2: Migrate v1 → v2 if needed ---
|
|
23
|
+
let tabs;
|
|
24
|
+
if (!config.tabs) {
|
|
25
|
+
// v1 config — migrate to v2
|
|
26
|
+
const v1Columns = config.columns ?? {};
|
|
27
|
+
const v1Actions = config.actions ?? [];
|
|
28
|
+
tabs = {
|
|
29
|
+
[tabGid]: {
|
|
30
|
+
name: targetName,
|
|
31
|
+
columns: { ...v1Columns },
|
|
32
|
+
actions: [...v1Actions],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
configChanged = true;
|
|
36
|
+
messages.push(`Migrated v1 config to v2 multi-tab format (tab GID: ${tabGid})`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Already v2 — deep clone tabs
|
|
40
|
+
tabs = {};
|
|
41
|
+
for (const [gid, tab] of Object.entries(config.tabs)) {
|
|
42
|
+
tabs[gid] = {
|
|
43
|
+
name: tab.name,
|
|
44
|
+
columns: { ...tab.columns },
|
|
45
|
+
actions: [...tab.actions],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Ensure the target tab exists in config
|
|
50
|
+
if (!tabs[tabGid]) {
|
|
51
|
+
tabs[tabGid] = {
|
|
52
|
+
name: targetName,
|
|
53
|
+
columns: {},
|
|
54
|
+
actions: [],
|
|
55
|
+
};
|
|
56
|
+
configChanged = true;
|
|
57
|
+
}
|
|
58
|
+
// --- Action 3: Reconcile tab names ---
|
|
59
|
+
for (const [gid, tab] of Object.entries(tabs)) {
|
|
60
|
+
const sheet = sheets.find((s) => String(s.gid) === gid);
|
|
61
|
+
if (sheet && sheet.name !== tab.name) {
|
|
62
|
+
messages.push(`Tab ${gid}: renamed "${tab.name}" → "${sheet.name}"`);
|
|
63
|
+
tab.name = sheet.name;
|
|
64
|
+
configChanged = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// --- Action 4: Reconcile columns for the target tab ---
|
|
68
|
+
const tabConfig = tabs[tabGid];
|
|
69
|
+
const headers = await adapter.getHeaders(ref);
|
|
70
|
+
const sheetRanges = await adapter.readColumnRanges(ref, targetSheet.gid);
|
|
71
|
+
let oldColumns = tabConfig.columns;
|
|
72
|
+
const newColumns = {};
|
|
73
|
+
if (Object.keys(oldColumns).length === 0 && !config.tabs) {
|
|
74
|
+
// First time — configChanged already set from migration
|
|
75
|
+
}
|
|
76
|
+
// Detect old {name: id} format and flip to new {id: name} format.
|
|
77
|
+
const isOldFormat = Object.keys(oldColumns).length > 0 &&
|
|
78
|
+
Object.values(oldColumns).some((v) => sheetRanges.has(v));
|
|
79
|
+
if (isOldFormat) {
|
|
80
|
+
const flipped = {};
|
|
81
|
+
for (const [name, id] of Object.entries(oldColumns)) {
|
|
82
|
+
flipped[id] = name;
|
|
83
|
+
}
|
|
84
|
+
oldColumns = flipped;
|
|
85
|
+
tabConfig.columns = flipped;
|
|
86
|
+
configChanged = true;
|
|
87
|
+
}
|
|
88
|
+
// Track which header indices are covered by existing ranges
|
|
89
|
+
const coveredIndices = new Set();
|
|
90
|
+
// --- Pass 1: Process all named ranges found in the sheet ---
|
|
91
|
+
for (const [rangeId, colIndex] of sheetRanges) {
|
|
92
|
+
const currentHeader = headers[colIndex];
|
|
93
|
+
if (!currentHeader) {
|
|
94
|
+
// Range points beyond current headers → column was deleted
|
|
95
|
+
const oldName = oldColumns[rangeId];
|
|
96
|
+
if (oldName) {
|
|
97
|
+
messages.push(`✗ "${oldName}" (${rangeId}) → deleted, removing`);
|
|
98
|
+
configChanged = true;
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
coveredIndices.add(colIndex);
|
|
103
|
+
const oldName = oldColumns[rangeId];
|
|
104
|
+
if (oldName && oldName !== currentHeader) {
|
|
105
|
+
// Header name changed — just update the label
|
|
106
|
+
newColumns[rangeId] = currentHeader;
|
|
107
|
+
messages.push(`✓ "${oldName}" → renamed to "${currentHeader}" (${rangeId})`);
|
|
108
|
+
configChanged = true;
|
|
109
|
+
}
|
|
110
|
+
else if (oldName) {
|
|
111
|
+
// No change
|
|
112
|
+
newColumns[rangeId] = currentHeader;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Range exists in sheet but not in our config (migration from action-based ranges)
|
|
116
|
+
newColumns[rangeId] = currentHeader;
|
|
117
|
+
configChanged = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// --- Pass 2: Create ranges for untracked headers ---
|
|
121
|
+
const trackedNames = new Set(Object.values(newColumns));
|
|
122
|
+
for (let i = 0; i < headers.length; i++) {
|
|
123
|
+
if (coveredIndices.has(i))
|
|
124
|
+
continue;
|
|
125
|
+
const header = headers[i];
|
|
126
|
+
if (!header || trackedNames.has(header))
|
|
127
|
+
continue;
|
|
128
|
+
const rangeId = randomBytes(4).toString("hex");
|
|
129
|
+
try {
|
|
130
|
+
await adapter.createColumnRange(ref, rangeId, i);
|
|
131
|
+
newColumns[rangeId] = header;
|
|
132
|
+
messages.push(`✓ "${header}" → new column (${rangeId})`);
|
|
133
|
+
configChanged = true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Non-fatal — column just won't have tracking
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// --- Pass 3: Migrate action targets from column names to IDs ---
|
|
140
|
+
const nameToId = new Map();
|
|
141
|
+
for (const [id, name] of Object.entries(newColumns)) {
|
|
142
|
+
nameToId.set(name, id);
|
|
143
|
+
}
|
|
144
|
+
const actions = tabConfig.actions.map((action) => {
|
|
145
|
+
// Target is already an ID (exists as key in columns)
|
|
146
|
+
if (newColumns[action.target] !== undefined) {
|
|
147
|
+
return action;
|
|
148
|
+
}
|
|
149
|
+
// Target is a column name — migrate to ID
|
|
150
|
+
const id = nameToId.get(action.target);
|
|
151
|
+
if (id) {
|
|
152
|
+
messages.push(`✓ action "${action.id}": target "${action.target}" → ${id} (migrated to ID)`);
|
|
153
|
+
return { ...action, target: id };
|
|
154
|
+
}
|
|
155
|
+
// Legacy migration: action.id was used as the range name in the old system
|
|
156
|
+
const legacyColIndex = sheetRanges.get(action.id);
|
|
157
|
+
if (legacyColIndex !== undefined) {
|
|
158
|
+
const currentHeader = headers[legacyColIndex];
|
|
159
|
+
if (currentHeader) {
|
|
160
|
+
const resolvedId = nameToId.get(currentHeader);
|
|
161
|
+
if (resolvedId) {
|
|
162
|
+
messages.push(`✓ action "${action.id}": target "${action.target}" → ${resolvedId} (legacy migration)`);
|
|
163
|
+
return { ...action, target: resolvedId };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return action;
|
|
168
|
+
});
|
|
169
|
+
if (actions.some((s, i) => s !== tabConfig.actions[i])) {
|
|
170
|
+
configChanged = true;
|
|
171
|
+
}
|
|
172
|
+
// Update tab config
|
|
173
|
+
tabConfig.columns = newColumns;
|
|
174
|
+
tabConfig.actions = actions;
|
|
175
|
+
// Build the final v2 config
|
|
176
|
+
const updatedConfig = {
|
|
177
|
+
...config,
|
|
178
|
+
version: "2",
|
|
179
|
+
tabs,
|
|
180
|
+
// Clear v1 top-level fields after migration
|
|
181
|
+
columns: undefined,
|
|
182
|
+
actions: [],
|
|
183
|
+
settings: config.settings,
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
config: updatedConfig,
|
|
187
|
+
tabGid,
|
|
188
|
+
tabConfig,
|
|
189
|
+
messages,
|
|
190
|
+
configChanged,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RunState } from "./run-state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Format milliseconds as a human-readable duration.
|
|
4
|
+
* Examples: "12s", "1m30s", "2h5m"
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatDuration(ms: number): string;
|
|
7
|
+
/**
|
|
8
|
+
* Format an ISO date as a relative "age" string.
|
|
9
|
+
* Examples: "just now", "5m ago", "2h ago", "3d ago", or "running" if status is running.
|
|
10
|
+
*/
|
|
11
|
+
export declare function formatAge(isoDate: string, status?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Format a list of runs as a compact table.
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* STATUS RUN SHEET ROWS UPDATES ERRORS DURATION AGE
|
|
17
|
+
* ✓ a1b2c3 EliteCart 30/30 28 0 12s 5m ago
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatRunList(runs: RunState[]): string;
|
|
21
|
+
/**
|
|
22
|
+
* Format a detailed view of a single run.
|
|
23
|
+
*
|
|
24
|
+
* ```
|
|
25
|
+
* ✗ Run d4e5f6 · LeadList
|
|
26
|
+
* Sheet: 1xABC...def · Started: 2h ago · Duration: 45s
|
|
27
|
+
*
|
|
28
|
+
* ACTIONS
|
|
29
|
+
* extract_domain ✓ 150/150
|
|
30
|
+
* enrich_company ✗ 147/150 (3 errors)
|
|
31
|
+
* find_email ⚠ 120/147 (27 skipped)
|
|
32
|
+
*
|
|
33
|
+
* ERRORS (3)
|
|
34
|
+
* Row 45 enrich_company 429 Too Many Requests (retries exhausted)
|
|
35
|
+
* Row 89 enrich_company timeout after 30s
|
|
36
|
+
* Row 102 enrich_company 404 → wrote "not_found"
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function formatRunDetail(run: RunState, errorsOnly?: boolean): string;
|