@msvesper/vesper-link 0.1.4
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.md +44 -0
- package/README.md +26 -0
- package/dist/atomic-json.d.ts +3 -0
- package/dist/atomic-json.js +47 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +170 -0
- package/dist/collectors/claude.d.ts +5 -0
- package/dist/collectors/claude.js +42 -0
- package/dist/collectors/codex.d.ts +5 -0
- package/dist/collectors/codex.js +65 -0
- package/dist/collectors/copilot.d.ts +5 -0
- package/dist/collectors/copilot.js +64 -0
- package/dist/collectors/index.d.ts +4 -0
- package/dist/collectors/index.js +3 -0
- package/dist/collectors/types.d.ts +16 -0
- package/dist/collectors/types.js +1 -0
- package/dist/companion.d.ts +12 -0
- package/dist/companion.js +62 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +41 -0
- package/dist/contracts.d.ts +83 -0
- package/dist/contracts.js +144 -0
- package/dist/diagnostics.d.ts +37 -0
- package/dist/diagnostics.js +64 -0
- package/dist/fs-utils.d.ts +5 -0
- package/dist/fs-utils.js +69 -0
- package/dist/hook-report.d.ts +8 -0
- package/dist/hook-report.js +21 -0
- package/dist/http-client.d.ts +25 -0
- package/dist/http-client.js +228 -0
- package/dist/install-args.d.ts +7 -0
- package/dist/install-args.js +35 -0
- package/dist/installation.d.ts +59 -0
- package/dist/installation.js +524 -0
- package/dist/installer.d.ts +59 -0
- package/dist/installer.js +214 -0
- package/dist/lock.d.ts +5 -0
- package/dist/lock.js +57 -0
- package/dist/managed-runtime.d.ts +33 -0
- package/dist/managed-runtime.js +130 -0
- package/dist/memory-mcp.d.ts +27 -0
- package/dist/memory-mcp.js +155 -0
- package/dist/operational-log.d.ts +17 -0
- package/dist/operational-log.js +48 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +36 -0
- package/dist/pending.d.ts +7 -0
- package/dist/pending.js +35 -0
- package/dist/process-runner.d.ts +11 -0
- package/dist/process-runner.js +21 -0
- package/dist/provider-integrations.d.ts +42 -0
- package/dist/provider-integrations.js +339 -0
- package/dist/queue.d.ts +19 -0
- package/dist/queue.js +129 -0
- package/dist/scheduler.d.ts +51 -0
- package/dist/scheduler.js +176 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +12 -0
- package/dist/source.d.ts +11 -0
- package/dist/source.js +45 -0
- package/dist/state.d.ts +11 -0
- package/dist/state.js +37 -0
- package/dist/sync.d.ts +18 -0
- package/dist/sync.js +247 -0
- package/dist/transport.d.ts +27 -0
- package/dist/transport.js +400 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +39 -0
package/dist/queue.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { defaultFormat, stableSessionKey } from './session.js';
|
|
3
|
+
export const STABLE_FILE_AGE_MS = 6 * 60 * 60 * 1000;
|
|
4
|
+
export const MAX_SESSIONS_PER_RUN = 2_000;
|
|
5
|
+
async function markerToCandidate(marker) {
|
|
6
|
+
let size = 0;
|
|
7
|
+
let mtimeMs = Date.parse(marker.queuedAt);
|
|
8
|
+
try {
|
|
9
|
+
const stat = await fs.stat(marker.transcriptPath);
|
|
10
|
+
size = stat.size;
|
|
11
|
+
mtimeMs = stat.mtimeMs;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== 'ENOENT') {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
provider: marker.provider,
|
|
20
|
+
format: defaultFormat(marker.provider),
|
|
21
|
+
formatVersion: 'observed-v1',
|
|
22
|
+
providerSessionId: marker.providerSessionId,
|
|
23
|
+
transcriptPath: marker.transcriptPath,
|
|
24
|
+
sourceUpdatedAt: new Date(mtimeMs).toISOString(),
|
|
25
|
+
size,
|
|
26
|
+
mtimeMs,
|
|
27
|
+
discovery: 'marker',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function stateToCandidate(state) {
|
|
31
|
+
let size = state.lastSeenSize;
|
|
32
|
+
let mtimeMs = state.lastSeenMtimeMs;
|
|
33
|
+
let sourceUpdatedAt = state.sourceUpdatedAt;
|
|
34
|
+
try {
|
|
35
|
+
const stat = await fs.stat(state.transcriptPath);
|
|
36
|
+
size = stat.size;
|
|
37
|
+
mtimeMs = stat.mtimeMs;
|
|
38
|
+
sourceUpdatedAt = stat.mtime.toISOString();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT') {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
provider: state.provider,
|
|
47
|
+
format: state.format,
|
|
48
|
+
formatVersion: state.formatVersion,
|
|
49
|
+
providerSessionId: state.providerSessionId,
|
|
50
|
+
transcriptPath: state.transcriptPath,
|
|
51
|
+
projectKey: state.projectKey,
|
|
52
|
+
cwdUri: state.cwdUri,
|
|
53
|
+
sourceCreatedAt: state.sourceCreatedAt,
|
|
54
|
+
sourceUpdatedAt,
|
|
55
|
+
size,
|
|
56
|
+
mtimeMs,
|
|
57
|
+
discovery: state.requiresStability ? 'scan' : 'state',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function unchangedSinceTerminalState(candidate, state) {
|
|
61
|
+
return (state?.status === 'uploaded' || state?.status === 'failed')
|
|
62
|
+
&& (!candidate.projectKey || candidate.projectKey === state.projectKey)
|
|
63
|
+
&& state.lastSeenSize === candidate.size
|
|
64
|
+
&& state.lastSeenMtimeMs === candidate.mtimeMs;
|
|
65
|
+
}
|
|
66
|
+
export function isStableForUpload(candidate, mtimeMs, nowMs) {
|
|
67
|
+
return candidate.discovery !== 'scan' || candidate.archived === true || nowMs - mtimeMs >= STABLE_FILE_AGE_MS;
|
|
68
|
+
}
|
|
69
|
+
function markerPredatesTerminalFailure(marker, state) {
|
|
70
|
+
if (state?.status !== 'failed' || !state.lastError) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return Date.parse(marker.queuedAt) <= Date.parse(state.lastError.at);
|
|
74
|
+
}
|
|
75
|
+
export async function selectQueueItems(options) {
|
|
76
|
+
const limit = options.limit ?? MAX_SESSIONS_PER_RUN;
|
|
77
|
+
const selected = new Map();
|
|
78
|
+
const scannedByKey = new Map(options.scanned.map((candidate) => [
|
|
79
|
+
stableSessionKey(candidate.provider, candidate.providerSessionId), candidate,
|
|
80
|
+
]));
|
|
81
|
+
const markerItems = [];
|
|
82
|
+
for (const { path: markerPath, marker } of options.markers) {
|
|
83
|
+
const key = stableSessionKey(marker.provider, marker.providerSessionId);
|
|
84
|
+
if (!markerPredatesTerminalFailure(marker, options.states.get(key))) {
|
|
85
|
+
const candidate = await markerToCandidate(marker);
|
|
86
|
+
const scanned = scannedByKey.get(key);
|
|
87
|
+
if (scanned) {
|
|
88
|
+
candidate.projectKey = scanned.projectKey;
|
|
89
|
+
candidate.cwdUri = scanned.cwdUri;
|
|
90
|
+
candidate.sourceCreatedAt = scanned.sourceCreatedAt;
|
|
91
|
+
}
|
|
92
|
+
markerItems.push({ candidate, markerPath, markerQueuedAt: marker.queuedAt });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
markerItems.sort((a, b) => a.candidate.sourceUpdatedAt.localeCompare(b.candidate.sourceUpdatedAt));
|
|
96
|
+
for (const item of markerItems) {
|
|
97
|
+
selected.set(stableSessionKey(item.candidate.provider, item.candidate.providerSessionId), item);
|
|
98
|
+
}
|
|
99
|
+
const pendingStates = [...options.states.values()]
|
|
100
|
+
.filter((state) => state.status === 'pending')
|
|
101
|
+
.sort((a, b) => (a.lastError?.at ?? '').localeCompare(b.lastError?.at ?? ''));
|
|
102
|
+
for (const state of pendingStates) {
|
|
103
|
+
const key = stableSessionKey(state.provider, state.providerSessionId);
|
|
104
|
+
if (!selected.has(key)) {
|
|
105
|
+
const candidate = await stateToCandidate(state);
|
|
106
|
+
const scanned = scannedByKey.get(key);
|
|
107
|
+
if (scanned) {
|
|
108
|
+
candidate.projectKey = scanned.projectKey;
|
|
109
|
+
candidate.cwdUri = scanned.cwdUri;
|
|
110
|
+
candidate.sourceCreatedAt = scanned.sourceCreatedAt;
|
|
111
|
+
}
|
|
112
|
+
selected.set(key, { candidate });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const eligibleScanned = options.scanned
|
|
116
|
+
.filter((candidate) => isStableForUpload(candidate, candidate.mtimeMs, options.nowMs))
|
|
117
|
+
.filter((candidate) => {
|
|
118
|
+
const key = stableSessionKey(candidate.provider, candidate.providerSessionId);
|
|
119
|
+
return !unchangedSinceTerminalState(candidate, options.states.get(key));
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
122
|
+
for (const candidate of eligibleScanned) {
|
|
123
|
+
const key = stableSessionKey(candidate.provider, candidate.providerSessionId);
|
|
124
|
+
if (!selected.has(key)) {
|
|
125
|
+
selected.set(key, { candidate });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...selected.values()].slice(0, limit);
|
|
129
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ProcessRunner } from './process-runner.js';
|
|
2
|
+
export type SchedulerState = {
|
|
3
|
+
kind: 'windows-task';
|
|
4
|
+
name: string;
|
|
5
|
+
installationId: string;
|
|
6
|
+
minuteOffset: number;
|
|
7
|
+
nodePath: string;
|
|
8
|
+
launcherPath: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'cron';
|
|
11
|
+
marker: string;
|
|
12
|
+
minuteOffset: number;
|
|
13
|
+
nodePath: string;
|
|
14
|
+
launcherPath: string;
|
|
15
|
+
};
|
|
16
|
+
export type SchedulerSnapshot = {
|
|
17
|
+
kind: 'windows-task';
|
|
18
|
+
existed: boolean;
|
|
19
|
+
xml?: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'cron';
|
|
22
|
+
content: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function stableMinuteOffset(homeDir: string, machineName?: string): number;
|
|
25
|
+
export declare function prepareScheduler(options: {
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
runner?: ProcessRunner;
|
|
28
|
+
minuteOffset: number;
|
|
29
|
+
nodePath: string;
|
|
30
|
+
launcherPath: string;
|
|
31
|
+
installationId: string;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
state: SchedulerState;
|
|
34
|
+
snapshot: SchedulerSnapshot;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function applyScheduler(state: SchedulerState, snapshot: SchedulerSnapshot, runner?: ProcessRunner): Promise<void>;
|
|
37
|
+
export declare function installScheduler(options: {
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
runner?: ProcessRunner;
|
|
40
|
+
minuteOffset: number;
|
|
41
|
+
nodePath: string;
|
|
42
|
+
launcherPath: string;
|
|
43
|
+
installationId: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
state: SchedulerState;
|
|
46
|
+
snapshot: SchedulerSnapshot;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function restoreScheduler(state: SchedulerState, snapshot: SchedulerSnapshot, runner?: ProcessRunner): Promise<void>;
|
|
49
|
+
export declare function schedulerInstalled(state: SchedulerState, runner?: ProcessRunner): Promise<boolean>;
|
|
50
|
+
export declare function uninstallScheduler(state: SchedulerState, runner?: ProcessRunner): Promise<void>;
|
|
51
|
+
export declare function assertSchedulerOwned(state: SchedulerState, runner?: ProcessRunner): Promise<void>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { NodeProcessRunner } from './process-runner.js';
|
|
6
|
+
const WINDOWS_TASK_PREFIX = 'VesperLinkSessionSync-';
|
|
7
|
+
const CRON_MARKER = '# vesper-link:session-sync';
|
|
8
|
+
function quoteCommand(value) {
|
|
9
|
+
return '"' + value.replace(/"/g, '\\"') + '"';
|
|
10
|
+
}
|
|
11
|
+
function shellQuote(value) {
|
|
12
|
+
return "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
13
|
+
}
|
|
14
|
+
function ownerToken(installationId) {
|
|
15
|
+
return '--owner ' + installationId;
|
|
16
|
+
}
|
|
17
|
+
export function stableMinuteOffset(homeDir, machineName = os.hostname()) {
|
|
18
|
+
return Number.parseInt(createHash('sha256').update(machineName).update('\0').update(homeDir).digest('hex').slice(0, 8), 16) % 60;
|
|
19
|
+
}
|
|
20
|
+
async function readCron(runner) {
|
|
21
|
+
const environment = { ...process.env, LC_ALL: 'C', LANG: 'C' };
|
|
22
|
+
const result = await runner.run('crontab', ['-l'], undefined, environment);
|
|
23
|
+
if (result.code === 0) {
|
|
24
|
+
return result.stdout;
|
|
25
|
+
}
|
|
26
|
+
const output = result.stdout + result.stderr;
|
|
27
|
+
if (result.code === 1 && /^\s*no crontab for [^\r\n]+\s*$/i.test(output)) {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
throw new Error('Cannot read user crontab: ' + (result.stderr || result.stdout).trim());
|
|
31
|
+
}
|
|
32
|
+
function withoutOwnedCron(content) {
|
|
33
|
+
return content.split(/\r?\n/).filter((line) => !line.trimEnd().endsWith(CRON_MARKER)).join('\n');
|
|
34
|
+
}
|
|
35
|
+
async function writeCron(runner, content) {
|
|
36
|
+
const environment = { ...process.env, LC_ALL: 'C', LANG: 'C' };
|
|
37
|
+
const result = await runner.run('crontab', ['-'], content, environment);
|
|
38
|
+
if (result.code !== 0) {
|
|
39
|
+
throw new Error('Cannot update user crontab: ' + result.stderr.trim());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function queryWindowsTask(runner, name) {
|
|
43
|
+
return runner.run('schtasks.exe', ['/Query', '/TN', name, '/XML']);
|
|
44
|
+
}
|
|
45
|
+
async function createWindowsTask(runner, state, replace) {
|
|
46
|
+
const command = quoteCommand(state.nodePath) + ' ' + quoteCommand(state.launcherPath)
|
|
47
|
+
+ ' scheduled-sync ' + ownerToken(state.installationId);
|
|
48
|
+
const args = [
|
|
49
|
+
'/Create', '/TN', state.name, '/SC', 'HOURLY', '/MO', '1',
|
|
50
|
+
'/ST', '00:' + String(state.minuteOffset).padStart(2, '0'), '/TR', command, '/RL', 'LIMITED',
|
|
51
|
+
];
|
|
52
|
+
if (replace) {
|
|
53
|
+
args.push('/F');
|
|
54
|
+
}
|
|
55
|
+
const result = await runner.run('schtasks.exe', args);
|
|
56
|
+
if (result.code !== 0) {
|
|
57
|
+
throw new Error('Cannot install Scheduled Task: ' + result.stderr.trim());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function prepareScheduler(options) {
|
|
61
|
+
const platform = options.platform ?? process.platform;
|
|
62
|
+
const runner = options.runner ?? new NodeProcessRunner();
|
|
63
|
+
if (platform === 'linux') {
|
|
64
|
+
const original = await readCron(runner);
|
|
65
|
+
return {
|
|
66
|
+
state: { kind: 'cron', marker: CRON_MARKER, minuteOffset: options.minuteOffset, nodePath: options.nodePath, launcherPath: options.launcherPath },
|
|
67
|
+
snapshot: { kind: 'cron', content: original },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (platform === 'win32') {
|
|
71
|
+
const name = WINDOWS_TASK_PREFIX + options.installationId;
|
|
72
|
+
const state = {
|
|
73
|
+
kind: 'windows-task', name, installationId: options.installationId, minuteOffset: options.minuteOffset,
|
|
74
|
+
nodePath: options.nodePath, launcherPath: options.launcherPath,
|
|
75
|
+
};
|
|
76
|
+
const current = await queryWindowsTask(runner, name);
|
|
77
|
+
if (current.code === 0 && !current.stdout.includes(ownerToken(options.installationId))) {
|
|
78
|
+
throw new Error('Scheduled Task name collision: ' + name);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
state,
|
|
82
|
+
snapshot: { kind: 'windows-task', existed: current.code === 0, xml: current.code === 0 ? current.stdout : undefined },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw new Error('Unsupported platform: ' + platform);
|
|
86
|
+
}
|
|
87
|
+
export async function applyScheduler(state, snapshot, runner = new NodeProcessRunner()) {
|
|
88
|
+
if (state.kind === 'cron' && snapshot.kind === 'cron') {
|
|
89
|
+
const current = withoutOwnedCron(snapshot.content);
|
|
90
|
+
const entry = String(state.minuteOffset) + ' * * * * ' + shellQuote(state.nodePath) + ' '
|
|
91
|
+
+ shellQuote(state.launcherPath) + ' scheduled-sync ' + CRON_MARKER;
|
|
92
|
+
const separator = current && !current.endsWith('\n') ? '\n' : '';
|
|
93
|
+
await writeCron(runner, current + separator + entry + '\n');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (state.kind === 'windows-task' && snapshot.kind === 'windows-task') {
|
|
97
|
+
await createWindowsTask(runner, state, snapshot.existed);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Scheduler apply kind mismatch');
|
|
101
|
+
}
|
|
102
|
+
export async function installScheduler(options) {
|
|
103
|
+
const prepared = await prepareScheduler(options);
|
|
104
|
+
await applyScheduler(prepared.state, prepared.snapshot, options.runner);
|
|
105
|
+
return prepared;
|
|
106
|
+
}
|
|
107
|
+
export async function restoreScheduler(state, snapshot, runner = new NodeProcessRunner()) {
|
|
108
|
+
if (state.kind === 'cron' && snapshot.kind === 'cron') {
|
|
109
|
+
await writeCron(runner, snapshot.content);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (state.kind !== 'windows-task' || snapshot.kind !== 'windows-task') {
|
|
113
|
+
throw new Error('Scheduler rollback kind mismatch');
|
|
114
|
+
}
|
|
115
|
+
if (!snapshot.existed) {
|
|
116
|
+
const query = await queryWindowsTask(runner, state.name);
|
|
117
|
+
if (query.code !== 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!query.stdout.includes(ownerToken(state.installationId))) {
|
|
121
|
+
throw new Error('Rollback found a non-owned Scheduled Task; refusing to delete ' + state.name);
|
|
122
|
+
}
|
|
123
|
+
const result = await runner.run('schtasks.exe', ['/Delete', '/TN', state.name, '/F']);
|
|
124
|
+
if (result.code !== 0) {
|
|
125
|
+
throw new Error('Cannot remove rolled-back Scheduled Task: ' + result.stderr.trim());
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const temporary = path.join(os.tmpdir(), 'vesper-link-task-' + process.pid + '-' + Date.now() + '.xml');
|
|
130
|
+
try {
|
|
131
|
+
await fs.writeFile(temporary, snapshot.xml, 'utf8');
|
|
132
|
+
const result = await runner.run('schtasks.exe', ['/Create', '/TN', state.name, '/XML', temporary, '/F']);
|
|
133
|
+
if (result.code !== 0) {
|
|
134
|
+
throw new Error('Cannot restore Scheduled Task: ' + result.stderr.trim());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await fs.rm(temporary, { force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function schedulerInstalled(state, runner = new NodeProcessRunner()) {
|
|
142
|
+
if (state.kind === 'cron') {
|
|
143
|
+
const cron = await readCron(runner);
|
|
144
|
+
return cron.includes(state.marker) && cron.includes(state.launcherPath);
|
|
145
|
+
}
|
|
146
|
+
const current = await queryWindowsTask(runner, state.name);
|
|
147
|
+
return current.code === 0 && current.stdout.includes(ownerToken(state.installationId))
|
|
148
|
+
&& current.stdout.includes(state.launcherPath);
|
|
149
|
+
}
|
|
150
|
+
export async function uninstallScheduler(state, runner = new NodeProcessRunner()) {
|
|
151
|
+
if (state.kind === 'cron') {
|
|
152
|
+
await writeCron(runner, withoutOwnedCron(await readCron(runner)));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const current = await queryWindowsTask(runner, state.name);
|
|
156
|
+
if (current.code !== 0 || !current.stdout.includes(ownerToken(state.installationId))) {
|
|
157
|
+
throw new Error('Scheduled Task ownership changed; refusing to delete ' + state.name);
|
|
158
|
+
}
|
|
159
|
+
const result = await runner.run('schtasks.exe', ['/Delete', '/TN', state.name, '/F']);
|
|
160
|
+
if (result.code !== 0) {
|
|
161
|
+
throw new Error('Cannot remove Scheduled Task: ' + result.stderr.trim());
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function assertSchedulerOwned(state, runner = new NodeProcessRunner()) {
|
|
165
|
+
if (state.kind === 'cron') {
|
|
166
|
+
const current = await readCron(runner);
|
|
167
|
+
if (!current.includes(state.marker)) {
|
|
168
|
+
throw new Error('Owned cron entry is missing; refusing a partial uninstall');
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const current = await queryWindowsTask(runner, state.name);
|
|
173
|
+
if (current.code !== 0 || !current.stdout.includes(ownerToken(state.installationId))) {
|
|
174
|
+
throw new Error('Scheduled Task ownership changed; refusing to modify ' + state.name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Provider } from './contracts.js';
|
|
2
|
+
export interface SessionCandidate {
|
|
3
|
+
provider: Provider;
|
|
4
|
+
format: string;
|
|
5
|
+
formatVersion: string;
|
|
6
|
+
providerSessionId: string;
|
|
7
|
+
transcriptPath: string;
|
|
8
|
+
projectKey?: string;
|
|
9
|
+
cwdUri?: string;
|
|
10
|
+
sourceCreatedAt?: string;
|
|
11
|
+
sourceUpdatedAt: string;
|
|
12
|
+
size: number;
|
|
13
|
+
mtimeMs: number;
|
|
14
|
+
discovery: 'marker' | 'state' | 'scan';
|
|
15
|
+
archived?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PendingMarker {
|
|
18
|
+
schemaVersion: 1;
|
|
19
|
+
provider: Provider;
|
|
20
|
+
providerSessionId: string;
|
|
21
|
+
transcriptPath: string;
|
|
22
|
+
queuedAt: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SessionState {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
provider: Provider;
|
|
27
|
+
format: string;
|
|
28
|
+
formatVersion: string;
|
|
29
|
+
providerSessionId: string;
|
|
30
|
+
transcriptPath: string;
|
|
31
|
+
projectKey?: string;
|
|
32
|
+
cwdUri?: string;
|
|
33
|
+
sourceCreatedAt?: string;
|
|
34
|
+
sourceUpdatedAt: string;
|
|
35
|
+
lastSeenSize: number;
|
|
36
|
+
lastSeenMtimeMs: number;
|
|
37
|
+
requiresStability?: boolean;
|
|
38
|
+
lastUploadedSha256?: string;
|
|
39
|
+
lastUploadedAt?: string;
|
|
40
|
+
status: 'pending' | 'uploaded' | 'failed';
|
|
41
|
+
pendingReason?: string;
|
|
42
|
+
missingSince?: string;
|
|
43
|
+
lastError?: {
|
|
44
|
+
code: string;
|
|
45
|
+
message: string;
|
|
46
|
+
at: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export declare function stableSessionKey(provider: Provider, providerSessionId: string): string;
|
|
50
|
+
export declare function defaultFormat(provider: Provider): string;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { PROVIDER_FORMATS } from './contracts.js';
|
|
3
|
+
export function stableSessionKey(provider, providerSessionId) {
|
|
4
|
+
return createHash('sha256')
|
|
5
|
+
.update(provider)
|
|
6
|
+
.update('\0')
|
|
7
|
+
.update(providerSessionId)
|
|
8
|
+
.digest('hex');
|
|
9
|
+
}
|
|
10
|
+
export function defaultFormat(provider) {
|
|
11
|
+
return PROVIDER_FORMATS[provider];
|
|
12
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SourceFingerprint {
|
|
2
|
+
size: number;
|
|
3
|
+
mtimeMs: number;
|
|
4
|
+
sha256: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function fingerprintStableSource(filePath: string): Promise<SourceFingerprint>;
|
|
7
|
+
export declare function assertSourceUnchanged(filePath: string, expected: Pick<SourceFingerprint, 'size' | 'mtimeMs'>): Promise<void>;
|
|
8
|
+
export declare class LocalTaskError extends Error {
|
|
9
|
+
readonly code: string;
|
|
10
|
+
constructor(code: string, message: string);
|
|
11
|
+
}
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { MAX_FILE_BYTES } from './contracts.js';
|
|
4
|
+
async function hashFile(filePath) {
|
|
5
|
+
const handle = await fs.open(filePath, 'r');
|
|
6
|
+
const hash = createHash('sha256');
|
|
7
|
+
try {
|
|
8
|
+
for await (const chunk of handle.createReadStream()) {
|
|
9
|
+
hash.update(chunk);
|
|
10
|
+
}
|
|
11
|
+
return hash.digest('hex');
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
await handle.close().catch(() => undefined);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function fingerprintStableSource(filePath) {
|
|
18
|
+
const before = await fs.stat(filePath);
|
|
19
|
+
if (!before.isFile()) {
|
|
20
|
+
throw new LocalTaskError('missing_source', 'Source is not a regular file');
|
|
21
|
+
}
|
|
22
|
+
if (before.size > MAX_FILE_BYTES) {
|
|
23
|
+
throw new LocalTaskError('file_too_large', `Source exceeds ${MAX_FILE_BYTES} bytes`);
|
|
24
|
+
}
|
|
25
|
+
const sha256 = await hashFile(filePath);
|
|
26
|
+
const after = await fs.stat(filePath);
|
|
27
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
28
|
+
throw new LocalTaskError('source_changed', 'Source changed while hashing');
|
|
29
|
+
}
|
|
30
|
+
return { size: after.size, mtimeMs: after.mtimeMs, sha256 };
|
|
31
|
+
}
|
|
32
|
+
export async function assertSourceUnchanged(filePath, expected) {
|
|
33
|
+
const stat = await fs.stat(filePath);
|
|
34
|
+
if (stat.size !== expected.size || stat.mtimeMs !== expected.mtimeMs) {
|
|
35
|
+
throw new LocalTaskError('source_changed', 'Source changed while uploading');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class LocalTaskError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
constructor(code, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.name = 'LocalTaskError';
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SessionState } from './session.js';
|
|
2
|
+
export declare function sessionStatePath(sessionsDir: string, state: Pick<SessionState, 'provider' | 'providerSessionId'>): string;
|
|
3
|
+
export declare function readSessionStates(sessionsDir: string): Promise<Map<string, SessionState>>;
|
|
4
|
+
export declare function writeSessionState(sessionsDir: string, state: SessionState): Promise<void>;
|
|
5
|
+
export interface SyncState {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
retryAfterUntil?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function readSyncState(filePath: string): Promise<SyncState>;
|
|
10
|
+
export declare function writeSyncState(filePath: string, state: SyncState): Promise<void>;
|
|
11
|
+
export declare function clearSyncState(filePath: string): Promise<void>;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import { listJsonFiles, readJsonFile, writeJsonAtomic } from './atomic-json.js';
|
|
4
|
+
import { stableSessionKey } from './session.js';
|
|
5
|
+
export function sessionStatePath(sessionsDir, state) {
|
|
6
|
+
return path.join(sessionsDir, `${stableSessionKey(state.provider, state.providerSessionId)}.json`);
|
|
7
|
+
}
|
|
8
|
+
export async function readSessionStates(sessionsDir) {
|
|
9
|
+
const states = new Map();
|
|
10
|
+
for (const filePath of await listJsonFiles(sessionsDir)) {
|
|
11
|
+
const state = await readJsonFile(filePath);
|
|
12
|
+
if (!state || state.schemaVersion !== 1 || !state.provider || !state.providerSessionId) {
|
|
13
|
+
throw new Error(`Malformed session state: ${filePath}`);
|
|
14
|
+
}
|
|
15
|
+
states.set(stableSessionKey(state.provider, state.providerSessionId), state);
|
|
16
|
+
}
|
|
17
|
+
return states;
|
|
18
|
+
}
|
|
19
|
+
export async function writeSessionState(sessionsDir, state) {
|
|
20
|
+
await writeJsonAtomic(sessionStatePath(sessionsDir, state), state);
|
|
21
|
+
}
|
|
22
|
+
export async function readSyncState(filePath) {
|
|
23
|
+
const state = await readJsonFile(filePath);
|
|
24
|
+
if (!state) {
|
|
25
|
+
return { schemaVersion: 1 };
|
|
26
|
+
}
|
|
27
|
+
if (state.schemaVersion !== 1 || (state.retryAfterUntil !== undefined && Number.isNaN(Date.parse(state.retryAfterUntil)))) {
|
|
28
|
+
throw new Error(`Malformed sync state: ${filePath}`);
|
|
29
|
+
}
|
|
30
|
+
return state;
|
|
31
|
+
}
|
|
32
|
+
export async function writeSyncState(filePath, state) {
|
|
33
|
+
await writeJsonAtomic(filePath, state);
|
|
34
|
+
}
|
|
35
|
+
export async function clearSyncState(filePath) {
|
|
36
|
+
await fs.rm(filePath, { force: true });
|
|
37
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CollectorIssue } from './collectors/index.js';
|
|
2
|
+
import { type LinkPaths } from './paths.js';
|
|
3
|
+
import { type RemoteTransport } from './transport.js';
|
|
4
|
+
export interface SyncSummary {
|
|
5
|
+
discovered: number;
|
|
6
|
+
queued: number;
|
|
7
|
+
uploaded: number;
|
|
8
|
+
unchanged: number;
|
|
9
|
+
failed: number;
|
|
10
|
+
collectorIssues: CollectorIssue[];
|
|
11
|
+
stoppedBy?: string;
|
|
12
|
+
alreadyRunning?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function runSyncOnce(options?: {
|
|
15
|
+
paths?: LinkPaths;
|
|
16
|
+
nowMs?: number;
|
|
17
|
+
acquireTransport?: (paths: LinkPaths, hostUrl: string) => Promise<RemoteTransport>;
|
|
18
|
+
}): Promise<SyncSummary>;
|