gipity 1.0.402 → 1.0.403
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/dist/api.js +46 -5
- package/dist/commands/page-eval.js +2 -0
- package/dist/commands/page-inspect.js +2 -0
- package/dist/commands/page-screenshot.js +2 -0
- package/dist/commands/test.js +41 -0
- package/dist/commands/uninstall.js +1 -1
- package/dist/hooks/capture-runner.js +77 -20
- package/dist/knowledge.js +1 -1
- package/dist/progress.js +37 -11
- package/dist/sync.js +88 -32
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -17,6 +17,28 @@ export class ApiError extends Error {
|
|
|
17
17
|
this.name = 'ApiError';
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
// Bound every network call so a wedged connection becomes a clear error instead
|
|
21
|
+
// of an unbounded hang (the "sync gets stuck forever" bug). JSON API calls must
|
|
22
|
+
// finish well within REQUEST_TIMEOUT_MS; the S3 PUT path keeps its own
|
|
23
|
+
// size-based timeout. Streaming downloads can't use a single total cap (a large
|
|
24
|
+
// but healthy tree would be cut off mid-stream), so downloadStream bounds only
|
|
25
|
+
// the time-to-first-byte here and an idle watchdog in sync.ts guards the body.
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
27
|
+
const DOWNLOAD_HEADER_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** fetch() that rejects with a clean 408 ApiError if the whole exchange (headers
|
|
29
|
+
* + body) doesn't complete within timeoutMs, instead of hanging forever. For
|
|
30
|
+
* request/response JSON calls only - never wrap a long streaming body in this. */
|
|
31
|
+
async function fetchWithTimeout(url, init, timeoutMs, label) {
|
|
32
|
+
try {
|
|
33
|
+
return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
|
|
37
|
+
throw new ApiError(408, 'REQUEST_TIMEOUT', `${label} timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
38
|
+
}
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
20
42
|
/** Resolve the Bearer token value. A GIPITY_TOKEN env var (a long-lived agent
|
|
21
43
|
* API token) takes precedence over the saved session — the persistent,
|
|
22
44
|
* login-free path for headless agents and CI. Falls back to the refreshed
|
|
@@ -55,11 +77,11 @@ export async function getAuthHeader() {
|
|
|
55
77
|
async function request(method, path, body) {
|
|
56
78
|
const headers = await getHeaders();
|
|
57
79
|
const url = `${baseUrl()}${path}`;
|
|
58
|
-
const res = await
|
|
80
|
+
const res = await fetchWithTimeout(url, {
|
|
59
81
|
method,
|
|
60
82
|
headers,
|
|
61
83
|
body: body ? JSON.stringify(body) : undefined,
|
|
62
|
-
});
|
|
84
|
+
}, REQUEST_TIMEOUT_MS, `${method} ${path}`);
|
|
63
85
|
if (!res.ok) {
|
|
64
86
|
const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
|
|
65
87
|
const err = json.error || { code: 'UNKNOWN', message: res.statusText };
|
|
@@ -150,9 +172,28 @@ export async function download(path) {
|
|
|
150
172
|
export async function downloadStream(path) {
|
|
151
173
|
const { Readable } = await import('stream');
|
|
152
174
|
const url = `${baseUrl()}${path}`;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
// Bound time-to-first-byte only: abort if no RESPONSE within the header window,
|
|
176
|
+
// then clear the timer so the (possibly large) body streams without a total cap.
|
|
177
|
+
// The body is guarded by an idle watchdog at the call site (sync.ts), which
|
|
178
|
+
// destroys this stream - cancelling the fetch - if bytes stop flowing.
|
|
179
|
+
const ac = new AbortController();
|
|
180
|
+
const headerTimer = setTimeout(() => ac.abort(), DOWNLOAD_HEADER_TIMEOUT_MS);
|
|
181
|
+
let res;
|
|
182
|
+
try {
|
|
183
|
+
res = await fetch(url, {
|
|
184
|
+
headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
|
|
185
|
+
signal: ac.signal,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (ac.signal.aborted) {
|
|
190
|
+
throw new ApiError(408, 'DOWNLOAD_TIMEOUT', `Download did not respond within ${DOWNLOAD_HEADER_TIMEOUT_MS / 1000}s`);
|
|
191
|
+
}
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
clearTimeout(headerTimer);
|
|
196
|
+
}
|
|
156
197
|
if (!res.ok) {
|
|
157
198
|
throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
|
|
158
199
|
}
|
|
@@ -148,6 +148,7 @@ export const pageEvalCommand = new Command('eval')
|
|
|
148
148
|
.option('--wait <ms>', 'Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)', '500')
|
|
149
149
|
.option('--wait-for <selector>', 'Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)')
|
|
150
150
|
.option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
|
|
151
|
+
.option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
|
|
151
152
|
.option('--json', 'Output as JSON')
|
|
152
153
|
.action((url, exprArg, opts) => run('Page eval', async () => {
|
|
153
154
|
// A JS-intent flag guess (captured as a hidden decoy below): redirect to the
|
|
@@ -205,6 +206,7 @@ export const pageEvalCommand = new Command('eval')
|
|
|
205
206
|
url, expr: sentExpr, waitMs,
|
|
206
207
|
waitForSelector: opts.waitFor || undefined,
|
|
207
208
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
209
|
+
auth: opts.auth || undefined,
|
|
208
210
|
});
|
|
209
211
|
const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
|
|
210
212
|
const { result, noValue } = normalizeEvalResult(d.result);
|
|
@@ -39,6 +39,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
39
39
|
.option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
|
|
40
40
|
.option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
|
|
41
41
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
|
|
42
|
+
.option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
|
|
42
43
|
// Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
|
|
43
44
|
// an image here (`page screenshot` is the single path for that) — just point there.
|
|
44
45
|
.addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
|
|
@@ -59,6 +60,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
59
60
|
waitForSelector: opts.waitFor || undefined,
|
|
60
61
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
61
62
|
fakeMedia: opts.fakeMedia || undefined,
|
|
63
|
+
auth: opts.auth || undefined,
|
|
62
64
|
};
|
|
63
65
|
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
64
66
|
const b = res.data;
|
|
@@ -108,6 +108,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
108
108
|
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
|
|
109
109
|
.option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
|
|
110
110
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
|
|
111
|
+
.option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
|
|
111
112
|
.option('--json', 'Output JSON metadata instead of a friendly summary')
|
|
112
113
|
.addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
|
|
113
114
|
// `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
|
|
@@ -142,6 +143,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
142
143
|
reloadBetween: opts.reloadBetween !== false,
|
|
143
144
|
...(userSpecifiedViewports ? { viewports: customViewports } : {}),
|
|
144
145
|
...(opts.fakeMedia ? { fakeMedia: true } : {}),
|
|
146
|
+
...(opts.auth ? { auth: true } : {}),
|
|
145
147
|
...(opts.action ? { action: opts.action } : {}),
|
|
146
148
|
};
|
|
147
149
|
// Load + render across viewports runs server-side and can take many
|
package/dist/commands/test.js
CHANGED
|
@@ -231,6 +231,47 @@ testCommand
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
}));
|
|
234
|
+
// ── List subcommand (show test files without running) ─────────────────
|
|
235
|
+
testCommand
|
|
236
|
+
.command('list')
|
|
237
|
+
.description('List the test files that would run — no run, optional path filter')
|
|
238
|
+
.argument('[path]', 'Test path filter (e.g. "api", "e2e/portal")')
|
|
239
|
+
.option('--json', 'Output as JSON')
|
|
240
|
+
// optsWithGlobals: see the status subcommand — `--json` lands on the parent.
|
|
241
|
+
.action((pathFilter, _o, command) => run('List', async () => {
|
|
242
|
+
const opts = command.optsWithGlobals();
|
|
243
|
+
const config = requireConfig();
|
|
244
|
+
const qs = pathFilter ? `?filterPath=${encodeURIComponent(pathFilter)}` : '';
|
|
245
|
+
const res = await get(`/projects/${config.projectGuid}/test/list${qs}`);
|
|
246
|
+
const { files, total } = res.data;
|
|
247
|
+
if (opts.json) {
|
|
248
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (total === 0) {
|
|
252
|
+
console.log(muted(pathFilter
|
|
253
|
+
? `No test files matched filter: ${pathFilter}`
|
|
254
|
+
: 'No test files found. Add *.test.js files under tests/.'));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ''}: ${total}`));
|
|
258
|
+
console.log('');
|
|
259
|
+
// Group by directory so the layout mirrors `gipity test` run output.
|
|
260
|
+
const groups = new Map();
|
|
261
|
+
for (const f of files) {
|
|
262
|
+
const key = f.path || '(root)';
|
|
263
|
+
if (!groups.has(key))
|
|
264
|
+
groups.set(key, []);
|
|
265
|
+
groups.get(key).push(f.name);
|
|
266
|
+
}
|
|
267
|
+
for (const [dir, names] of groups) {
|
|
268
|
+
console.log(` ${dim(dir)}`);
|
|
269
|
+
for (const name of names)
|
|
270
|
+
console.log(` ${name}`);
|
|
271
|
+
}
|
|
272
|
+
console.log('');
|
|
273
|
+
console.log(muted(`Run them: gipity test${pathFilter ? ` ${pathFilter}` : ''}`));
|
|
274
|
+
}));
|
|
234
275
|
// ── History subcommand ─────────────────────────────────────────────────
|
|
235
276
|
testCommand
|
|
236
277
|
.command('history')
|
|
@@ -181,6 +181,6 @@ export const uninstallCommand = new Command('uninstall')
|
|
|
181
181
|
}
|
|
182
182
|
console.log('');
|
|
183
183
|
console.log(`${success('Uninstall complete.')} ${dim('Run')} ${brand('npm uninstall -g gipity')} ${dim('to remove the binary too.')}`);
|
|
184
|
-
console.log(`${dim('Then run')} ${brand('hash -r')} ${dim('(or open a new shell)
|
|
184
|
+
console.log(`${dim('Then run')} ${brand('hash -r')} ${dim('(or open a new shell) - your shell caches the old binary path, and a reinstall may place it elsewhere.')}`);
|
|
185
185
|
});
|
|
186
186
|
//# sourceMappingURL=uninstall.js.map
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* - Anything unexpected (parse error, network error, etc.). We must
|
|
24
24
|
* not break the user's interactive session.
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, createReadStream, } from 'fs';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, utimesSync, createReadStream, } from 'fs';
|
|
27
27
|
import { homedir } from 'os';
|
|
28
28
|
import { join } from 'path';
|
|
29
29
|
import { getDevice } from '../relay/state.js';
|
|
@@ -74,33 +74,90 @@ function deleteState(convGuid) {
|
|
|
74
74
|
}
|
|
75
75
|
catch { /* already gone */ }
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
// Crash-safe reclaim, mirroring the advisory lock in src/sync.ts: a holder
|
|
78
|
+
// writes its PID and heartbeats the lock's mtime; a peer reclaims it when the
|
|
79
|
+
// holder crashed (dead PID), died before writing a PID (empty/garbage file), or
|
|
80
|
+
// went silent past the stale window (wedged, or its PID was reused by an
|
|
81
|
+
// unrelated process). Without this a SIGKILL'd hook would strand the lock and
|
|
82
|
+
// silently disable capture for that conversation until SessionEnd.
|
|
83
|
+
const LOCK_HEARTBEAT_MS = 15_000;
|
|
84
|
+
export const LOCK_STALE_MS = 90_000;
|
|
85
|
+
/** Decide whether an existing capture lock is reclaimable. Exported for tests.
|
|
86
|
+
* Kept in sync with sync.ts's namesake. */
|
|
87
|
+
export function isLockReclaimable(path, now = Date.now()) {
|
|
88
|
+
let raw;
|
|
89
|
+
let mtimeMs;
|
|
88
90
|
try {
|
|
89
|
-
|
|
91
|
+
raw = readFileSync(path, 'utf-8').trim();
|
|
92
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
90
93
|
}
|
|
91
94
|
catch {
|
|
92
|
-
return
|
|
95
|
+
return false; // unreadable / already gone - don't steal, just skip
|
|
96
|
+
}
|
|
97
|
+
const pid = parseInt(raw, 10);
|
|
98
|
+
if (!raw || !pid || isNaN(pid))
|
|
99
|
+
return true; // empty/garbage = crashed mid-create
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
93
102
|
}
|
|
94
|
-
|
|
103
|
+
catch {
|
|
104
|
+
return true;
|
|
105
|
+
} // holder PID is dead
|
|
106
|
+
return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
|
|
107
|
+
}
|
|
108
|
+
/** Exclusive file-lock via `wx` open, with crash-safe reclaim. Stop and
|
|
109
|
+
* SubagentStop can fire concurrently on the same conv (e.g. a Task subagent
|
|
110
|
+
* finishing while the parent is also wrapping up), and a crashed hook retry
|
|
111
|
+
* would race the next one. Holding a lock for the duration serializes them.
|
|
112
|
+
* Returns a releaser, or null when a *live* holder is already flushing - in
|
|
113
|
+
* which case we skip this run; the holder will catch our data on its next
|
|
114
|
+
* transcript scan. A dead/abandoned holder's lock is reclaimed once and
|
|
115
|
+
* retried rather than waited on (capture is fire-and-forget; we never block
|
|
116
|
+
* the user's session). Exported for tests. */
|
|
117
|
+
export function acquireLock(convGuid) {
|
|
118
|
+
mkdirSync(CAPTURE_DIR, { recursive: true });
|
|
119
|
+
const path = lockPath(convGuid);
|
|
120
|
+
// At most one reclaim+retry: a genuinely live holder is flushing the same
|
|
121
|
+
// transcript, so we just skip; only a crashed/stale holder is stolen.
|
|
122
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
123
|
+
let fd;
|
|
95
124
|
try {
|
|
96
|
-
|
|
125
|
+
fd = openSync(path, 'wx'); // fails if the file exists
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
if (attempt === 0 && isLockReclaimable(path)) {
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
}
|
|
132
|
+
catch { /* race - someone else got it */ }
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
return null; // live holder (or lost the reclaim race) - let it cover us
|
|
97
136
|
}
|
|
98
|
-
catch { /* ignore */ }
|
|
99
137
|
try {
|
|
100
|
-
|
|
138
|
+
writeFileSync(fd, String(process.pid));
|
|
101
139
|
}
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
finally {
|
|
141
|
+
closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
// Heartbeat the mtime so a long flush isn't mistaken for abandoned. unref()
|
|
144
|
+
// so the timer never keeps the hook process alive on its own.
|
|
145
|
+
const beat = setInterval(() => {
|
|
146
|
+
try {
|
|
147
|
+
utimesSync(path, new Date(), new Date());
|
|
148
|
+
}
|
|
149
|
+
catch { /* lock gone */ }
|
|
150
|
+
}, LOCK_HEARTBEAT_MS);
|
|
151
|
+
beat.unref?.();
|
|
152
|
+
return () => {
|
|
153
|
+
clearInterval(beat);
|
|
154
|
+
try {
|
|
155
|
+
unlinkSync(path);
|
|
156
|
+
}
|
|
157
|
+
catch { /* already gone */ }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
104
161
|
}
|
|
105
162
|
async function readStdin() {
|
|
106
163
|
if (process.stdin.isTTY)
|
package/dist/knowledge.js
CHANGED
|
@@ -128,7 +128,7 @@ Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under
|
|
|
128
128
|
- **\`src/\`** - the app itself. Synced **and** deployed to the CDN. Only app code, assets, and pages belong here.
|
|
129
129
|
- **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.)
|
|
130
130
|
- **\`docs/\`** - reference material you want to keep: UI/architecture diagrams, design decks, notes, ADRs. Synced and versioned on the server (backed up, rollback-able) but **never deployed**, because it's outside \`src/\`. This is the home for "keep forever, don't ship" artifacts.
|
|
131
|
-
- **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed.
|
|
131
|
+
- **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed. \`gipity test list [path]\` lists the test files (and what a filter selects) without running them.
|
|
132
132
|
|
|
133
133
|
Rule of thumb: shipping to users → \`src/\`; keep as reference → \`docs/\`; throwaway → \`tmp/\`.
|
|
134
134
|
|
package/dist/progress.js
CHANGED
|
@@ -22,10 +22,13 @@ const CLEAR_TO_EOL = '\x1b[K';
|
|
|
22
22
|
const BAR_WIDTH = 18;
|
|
23
23
|
const RENDER_THROTTLE_MS = 60;
|
|
24
24
|
// Indeterminate bounce: a short block sliding across the same BAR_WIDTH track
|
|
25
|
-
// the determinate bar fills.
|
|
26
|
-
//
|
|
25
|
+
// the determinate bar fills. Redraw cadence steps down once the wait gets long:
|
|
26
|
+
// a snappy 10×/s under 10s, then once a second so a long wait reads as a calm
|
|
27
|
+
// clock rather than a frantic blur.
|
|
27
28
|
const SPIN_BLOCK = 5;
|
|
28
|
-
const
|
|
29
|
+
const SPIN_FRAME_FAST_MS = 100;
|
|
30
|
+
const SPIN_FRAME_SLOW_MS = 1000;
|
|
31
|
+
const SPIN_SLOWDOWN_AFTER_MS = 10_000;
|
|
29
32
|
/** Compact elapsed: "8s", "1m 04s". Keeps the timer narrow and glanceable. */
|
|
30
33
|
function formatElapsed(ms) {
|
|
31
34
|
const s = Math.floor(ms / 1000);
|
|
@@ -34,12 +37,25 @@ function formatElapsed(ms) {
|
|
|
34
37
|
const m = Math.floor(s / 60);
|
|
35
38
|
return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Spinner clock. Under 10s shows tenths ("2.2s") so a fresh wait visibly moves;
|
|
42
|
+
* at/after 10s switches to m:ss ("0:11", "1:02") - whole seconds are plenty once
|
|
43
|
+
* the redraw has slowed to once a second.
|
|
44
|
+
*/
|
|
45
|
+
function formatSpinClock(ms) {
|
|
46
|
+
if (ms < SPIN_SLOWDOWN_AFTER_MS)
|
|
47
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
48
|
+
const total = Math.floor(ms / 1000);
|
|
49
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
50
|
+
}
|
|
37
51
|
class TerminalProgress {
|
|
38
52
|
/** True while an in-place transfer/spinner line is on screen and not committed. */
|
|
39
53
|
liveOpen = false;
|
|
40
54
|
lastRenderAt = 0;
|
|
41
55
|
/** The label of the current transfer session; a change starts a fresh one. */
|
|
42
56
|
barLabel = null;
|
|
57
|
+
/** Wall-clock start of the current transfer session, for the elapsed timer. */
|
|
58
|
+
barStartedAt = 0;
|
|
43
59
|
/** True once the current session hit 100% - late/overshoot ticks are dropped. */
|
|
44
60
|
barSettled = false;
|
|
45
61
|
/** Active indeterminate spinner timer, if any. */
|
|
@@ -60,6 +76,7 @@ class TerminalProgress {
|
|
|
60
76
|
if (label !== this.barLabel) {
|
|
61
77
|
this.barLabel = label;
|
|
62
78
|
this.barSettled = false;
|
|
79
|
+
this.barStartedAt = Date.now();
|
|
63
80
|
}
|
|
64
81
|
if (this.barSettled)
|
|
65
82
|
return;
|
|
@@ -82,13 +99,16 @@ class TerminalProgress {
|
|
|
82
99
|
const startedAt = Date.now();
|
|
83
100
|
let tick = 0;
|
|
84
101
|
const draw = () => {
|
|
102
|
+
const elapsed = Date.now() - startedAt;
|
|
85
103
|
this.liveOpen = true;
|
|
86
|
-
process.stdout.write('\r' + this.spinFrame(
|
|
104
|
+
process.stdout.write('\r' + this.spinFrame(tick++, elapsed) + CLEAR_TO_EOL);
|
|
105
|
+
// Reschedule each frame so the cadence can step down as the wait lengthens.
|
|
106
|
+
const next = elapsed < SPIN_SLOWDOWN_AFTER_MS ? SPIN_FRAME_FAST_MS : SPIN_FRAME_SLOW_MS;
|
|
107
|
+
this.spinTimer = setTimeout(draw, next);
|
|
108
|
+
// Don't let the animation keep the event loop (and process) alive on its own.
|
|
109
|
+
this.spinTimer.unref?.();
|
|
87
110
|
};
|
|
88
111
|
draw();
|
|
89
|
-
this.spinTimer = setInterval(draw, SPIN_FRAME_MS);
|
|
90
|
-
// Don't let the animation keep the event loop (and process) alive on its own.
|
|
91
|
-
this.spinTimer.unref?.();
|
|
92
112
|
const settle = (icon, message) => {
|
|
93
113
|
this.stopSpinTimer();
|
|
94
114
|
if (this.liveOpen)
|
|
@@ -118,7 +138,7 @@ class TerminalProgress {
|
|
|
118
138
|
}
|
|
119
139
|
stopSpinTimer() {
|
|
120
140
|
if (this.spinTimer) {
|
|
121
|
-
|
|
141
|
+
clearTimeout(this.spinTimer);
|
|
122
142
|
this.spinTimer = null;
|
|
123
143
|
}
|
|
124
144
|
}
|
|
@@ -133,9 +153,14 @@ class TerminalProgress {
|
|
|
133
153
|
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
134
154
|
const bar = brand('█'.repeat(filled)) + dim('░'.repeat(BAR_WIDTH - filled));
|
|
135
155
|
const sizes = muted(`${formatSize(done)} / ${formatSize(total)}`);
|
|
136
|
-
|
|
156
|
+
// Same built-in elapsed timer the spinner carries, so a determinate transfer
|
|
157
|
+
// that stalls mid-flight (slow upload, wedged S3 PUT) still visibly ticks
|
|
158
|
+
// instead of freezing at "80%" - and the in-place line and the committed
|
|
159
|
+
// 100% frame both show how long the transfer took.
|
|
160
|
+
const elapsed = muted(formatElapsed(Date.now() - this.barStartedAt));
|
|
161
|
+
return ` ${muted(label)} ${bar} ${brandBold(`${pct}%`)} ${sizes} ${elapsed}`;
|
|
137
162
|
}
|
|
138
|
-
spinFrame(
|
|
163
|
+
spinFrame(tick, elapsedMs) {
|
|
139
164
|
// Ping-pong the block's left edge between 0 and (BAR_WIDTH - SPIN_BLOCK).
|
|
140
165
|
const span = BAR_WIDTH - SPIN_BLOCK;
|
|
141
166
|
const cycle = span * 2;
|
|
@@ -144,7 +169,8 @@ class TerminalProgress {
|
|
|
144
169
|
const bar = dim('░'.repeat(pos)) +
|
|
145
170
|
brand('█'.repeat(SPIN_BLOCK)) +
|
|
146
171
|
dim('░'.repeat(BAR_WIDTH - pos - SPIN_BLOCK));
|
|
147
|
-
|
|
172
|
+
// No label: a bare track plus a clock, framed by three spaces on each side.
|
|
173
|
+
return ` ${bar} ${muted(formatSpinClock(elapsedMs))}`;
|
|
148
174
|
}
|
|
149
175
|
}
|
|
150
176
|
const NOOP_SPINNER = { succeed() { }, fail() { }, stop() { } };
|
package/dist/sync.js
CHANGED
|
@@ -38,6 +38,10 @@ import * as tar from 'tar-stream';
|
|
|
38
38
|
* project probably are intentional. */
|
|
39
39
|
const BULK_DELETE_COUNT = 10;
|
|
40
40
|
const BULK_DELETE_FRACTION = 0.25;
|
|
41
|
+
/** A tar download that stops producing bytes for this long - without ever
|
|
42
|
+
* ending the stream - is treated as a stall and aborted, so a wedged
|
|
43
|
+
* connection becomes a recoverable error instead of an unbounded hang. */
|
|
44
|
+
const DOWNLOAD_IDLE_MS = 30_000;
|
|
41
45
|
// ─── Paths ─────────────────────────────────────────────────────
|
|
42
46
|
function syncStatePath() {
|
|
43
47
|
const configPath = getConfigPath();
|
|
@@ -89,11 +93,28 @@ export function isLockReclaimable(path, now = Date.now()) {
|
|
|
89
93
|
} // holder PID is dead
|
|
90
94
|
return now - mtimeMs > LOCK_STALE_MS; // alive but heartbeat went silent
|
|
91
95
|
}
|
|
92
|
-
/** Acquire the per-project sync lock. Returns a release function. Exported for tests.
|
|
93
|
-
|
|
96
|
+
/** Acquire the per-project sync lock. Returns a release function. Exported for tests.
|
|
97
|
+
* Pass `progress` so a *contended* wait (another sync/push holds the lock) shows a
|
|
98
|
+
* live "Waiting for another sync…" spinner instead of a silent stall - the lock is
|
|
99
|
+
* taken before any sync phase prints, so without this an agent or user staring at a
|
|
100
|
+
* frozen terminal can't tell a 30s lock wait from a genuine hang. The spinner is
|
|
101
|
+
* created lazily, only when we actually have to wait, so the common instant-acquire
|
|
102
|
+
* path stays output-free (and on a non-TTY the reporter is a no-op regardless). */
|
|
103
|
+
export async function acquireLock(progress) {
|
|
94
104
|
const path = lockPath();
|
|
95
105
|
mkdirSync(dirname(path), { recursive: true });
|
|
96
106
|
const start = Date.now();
|
|
107
|
+
// Lazily-opened spinner for the contended case; settled before we return.
|
|
108
|
+
let waitSpinner = null;
|
|
109
|
+
const settleWait = (ok) => {
|
|
110
|
+
if (!waitSpinner)
|
|
111
|
+
return;
|
|
112
|
+
if (ok)
|
|
113
|
+
waitSpinner.stop();
|
|
114
|
+
else
|
|
115
|
+
waitSpinner.fail('Gave up waiting for the sync lock');
|
|
116
|
+
waitSpinner = null;
|
|
117
|
+
};
|
|
97
118
|
while (true) {
|
|
98
119
|
try {
|
|
99
120
|
const fd = openSync(path, 'wx');
|
|
@@ -108,6 +129,7 @@ export async function acquireLock() {
|
|
|
108
129
|
catch { /* lock gone */ }
|
|
109
130
|
}, LOCK_HEARTBEAT_MS);
|
|
110
131
|
beat.unref?.();
|
|
132
|
+
settleWait(true);
|
|
111
133
|
return () => { clearInterval(beat); try {
|
|
112
134
|
unlinkSync(path);
|
|
113
135
|
}
|
|
@@ -124,9 +146,15 @@ export async function acquireLock() {
|
|
|
124
146
|
continue;
|
|
125
147
|
}
|
|
126
148
|
if (Date.now() - start > LOCK_WAIT_MS) {
|
|
149
|
+
settleWait(false);
|
|
127
150
|
throw new Error(`Another sync is in progress (${path}). Waited ${LOCK_WAIT_MS / 1000}s. ` +
|
|
128
151
|
`Remove the file manually if you're sure no sync is running.`);
|
|
129
152
|
}
|
|
153
|
+
// First time we're forced to wait: open the spinner so the wait is visible
|
|
154
|
+
// (with an elapsed timer) rather than reading as a frozen process.
|
|
155
|
+
if (!waitSpinner) {
|
|
156
|
+
waitSpinner = progress?.spinner('Waiting for another sync to finish…') ?? null;
|
|
157
|
+
}
|
|
130
158
|
await new Promise(r => setTimeout(r, LOCK_POLL_MS));
|
|
131
159
|
}
|
|
132
160
|
}
|
|
@@ -257,27 +285,67 @@ async function fetchRemote(projectGuid) {
|
|
|
257
285
|
}
|
|
258
286
|
return out;
|
|
259
287
|
}
|
|
260
|
-
|
|
261
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Extract a tar stream into a path→bytes map, guarded by an idle watchdog.
|
|
290
|
+
*
|
|
291
|
+
* The sync hang was here: the server delivered every byte (progress bar hit
|
|
292
|
+
* 100%) but never cleanly ended the stream, so tar's 'finish' never fired and
|
|
293
|
+
* the awaiting Promise never settled - an unbounded hang. The watchdog turns
|
|
294
|
+
* that into a recoverable error: if no bytes arrive for idleMs without the
|
|
295
|
+
* stream ending, destroy it (closing the socket) and reject. pipe() also doesn't
|
|
296
|
+
* forward source errors to the destination, so we reject on a source 'error' too
|
|
297
|
+
* - a truncated body must never look like a clean 'finish' with partial files.
|
|
298
|
+
*
|
|
299
|
+
* `keep` filters which entries to buffer (default: all); every entry is still
|
|
300
|
+
* drained so the stream can progress. Exported for tests.
|
|
301
|
+
*/
|
|
302
|
+
export function extractTarToMap(stream, idleMs, onBytes, keep) {
|
|
262
303
|
const extract = tar.extract();
|
|
263
304
|
const files = new Map();
|
|
264
305
|
return new Promise((resolve, reject) => {
|
|
306
|
+
let settled = false;
|
|
307
|
+
let idle;
|
|
308
|
+
const arm = () => {
|
|
309
|
+
clearTimeout(idle);
|
|
310
|
+
idle = setTimeout(() => {
|
|
311
|
+
if (settled)
|
|
312
|
+
return;
|
|
313
|
+
settled = true;
|
|
314
|
+
const e = new Error(`download stalled: no data for ${idleMs / 1000}s`);
|
|
315
|
+
stream.destroy(e);
|
|
316
|
+
reject(e);
|
|
317
|
+
}, idleMs);
|
|
318
|
+
idle.unref?.();
|
|
319
|
+
};
|
|
320
|
+
const done = (fn, arg) => {
|
|
321
|
+
if (settled)
|
|
322
|
+
return;
|
|
323
|
+
settled = true;
|
|
324
|
+
clearTimeout(idle);
|
|
325
|
+
fn(arg);
|
|
326
|
+
};
|
|
265
327
|
extract.on('entry', (header, entryStream, next) => {
|
|
328
|
+
arm();
|
|
329
|
+
const name = normalizeTreePath(header.name);
|
|
330
|
+
const wanted = keep ? keep(name) : true;
|
|
266
331
|
const chunks = [];
|
|
267
|
-
entryStream.on('data', (c) => {
|
|
268
|
-
|
|
332
|
+
entryStream.on('data', (c) => { if (wanted)
|
|
333
|
+
chunks.push(c); onBytes?.(c.length); arm(); });
|
|
334
|
+
entryStream.on('end', () => { if (wanted)
|
|
335
|
+
files.set(name, Buffer.concat(chunks)); next(); });
|
|
269
336
|
entryStream.resume();
|
|
270
337
|
});
|
|
271
|
-
extract.on('finish', () => resolve
|
|
272
|
-
extract.on('error', reject);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// 'finish' with a partial file set. Reject explicitly so a short download is
|
|
276
|
-
// an error the caller can recover from, never a silent partial.
|
|
277
|
-
stream.on('error', reject);
|
|
338
|
+
extract.on('finish', () => done(resolve, files));
|
|
339
|
+
extract.on('error', (e) => done(reject, e));
|
|
340
|
+
stream.on('error', (e) => done(reject, e));
|
|
341
|
+
arm();
|
|
278
342
|
stream.pipe(extract);
|
|
279
343
|
});
|
|
280
344
|
}
|
|
345
|
+
async function downloadAll(projectGuid, onBytes) {
|
|
346
|
+
const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
|
|
347
|
+
return extractTarToMap(stream, DOWNLOAD_IDLE_MS, onBytes);
|
|
348
|
+
}
|
|
281
349
|
async function fetchOne(projectGuid, path) {
|
|
282
350
|
// Exact single-file read first. The tree-tar endpoint below treats its `path`
|
|
283
351
|
// as a DIRECTORY prefix, so a single root file (e.g. `gipity.yaml`) comes back
|
|
@@ -297,24 +365,12 @@ async function fetchOne(projectGuid, path) {
|
|
|
297
365
|
}
|
|
298
366
|
try {
|
|
299
367
|
const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar&path=${encodeURIComponent(path)}`);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
entryStream.on('data', (c) => chunks.push(c));
|
|
307
|
-
entryStream.on('end', () => { found = Buffer.concat(chunks); next(); });
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
entryStream.on('end', () => next());
|
|
311
|
-
}
|
|
312
|
-
entryStream.resume();
|
|
313
|
-
});
|
|
314
|
-
extract.on('finish', () => resolve(found));
|
|
315
|
-
extract.on('error', reject);
|
|
316
|
-
stream.pipe(extract);
|
|
317
|
-
});
|
|
368
|
+
// Same idle-guarded extraction as the bulk path; keep only the one entry we
|
|
369
|
+
// asked for. The recovery path must not hang either, or a single stalled file
|
|
370
|
+
// wedges the whole sync.
|
|
371
|
+
const want = normalizeTreePath(path);
|
|
372
|
+
const files = await extractTarToMap(stream, DOWNLOAD_IDLE_MS, undefined, (p) => p === want);
|
|
373
|
+
return files.get(want) ?? null;
|
|
318
374
|
}
|
|
319
375
|
catch {
|
|
320
376
|
return null;
|
|
@@ -569,7 +625,7 @@ export async function sync(opts = {}) {
|
|
|
569
625
|
const root = projectDir();
|
|
570
626
|
const interactive = opts.interactive ?? process.stdout.isTTY ?? false;
|
|
571
627
|
const ignore = effectiveIgnore(root, config.ignore);
|
|
572
|
-
const releaseLock = await acquireLock();
|
|
628
|
+
const releaseLock = await acquireLock(opts.progress);
|
|
573
629
|
try {
|
|
574
630
|
return await syncInner(config.projectGuid, root, ignore, opts, interactive);
|
|
575
631
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.403",
|
|
4
4
|
"description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gipity": "dist/updater/shim.js",
|