@mndrk/agx 1.4.64 → 1.4.65
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/lib/cli/runCli.js +213 -2
- package/lib/cloud-sync.js +267 -0
- package/lib/executor.js +315 -9
- package/lib/prompts/resume.js +353 -0
- package/lib/storage/checkpoints.js +222 -0
- package/lib/storage/git.js +147 -0
- package/lib/storage/index.js +7 -0
- package/lib/verify-gate.js +301 -0
- package/package.json +1 -1
package/lib/cli/runCli.js
CHANGED
|
@@ -704,7 +704,7 @@ async function checkOnboarding() {
|
|
|
704
704
|
}
|
|
705
705
|
|
|
706
706
|
async function runTaskInline(rawTaskId, options = {}) {
|
|
707
|
-
const { resetFirst = false, forceSwarm = false, fromStage = null } = options;
|
|
707
|
+
const { resetFirst = false, forceSwarm = false, fromStage = null, resumePrompt = '' } = options;
|
|
708
708
|
const taskId = await resolveTaskId(rawTaskId);
|
|
709
709
|
|
|
710
710
|
if (fromStage) {
|
|
@@ -736,7 +736,11 @@ async function checkOnboarding() {
|
|
|
736
736
|
throw new Error(`Task not found: ${taskId}`);
|
|
737
737
|
}
|
|
738
738
|
|
|
739
|
-
const effectiveTask = forceSwarm ? { ...task, swarm: true } : task;
|
|
739
|
+
const effectiveTask = forceSwarm ? { ...task, swarm: true } : { ...task };
|
|
740
|
+
const normalizedResumePrompt = typeof resumePrompt === 'string' ? resumePrompt.trim() : '';
|
|
741
|
+
if (normalizedResumePrompt) {
|
|
742
|
+
effectiveTask.prompt = normalizedResumePrompt;
|
|
743
|
+
}
|
|
740
744
|
console.log(`${c.green}✓${c.reset} Running task inline`);
|
|
741
745
|
console.log(`${c.dim}Task: ${taskId}${c.reset}`);
|
|
742
746
|
|
|
@@ -1801,6 +1805,213 @@ async function checkOnboarding() {
|
|
|
1801
1805
|
process.exit(0);
|
|
1802
1806
|
}
|
|
1803
1807
|
|
|
1808
|
+
// agx resume [<project>:]<task> [--from <checkpoint-id>] [--git-restore] [--dry-run]
|
|
1809
|
+
// Resume execution from last checkpoint (or specified checkpoint)
|
|
1810
|
+
if (cmd === 'resume') {
|
|
1811
|
+
const { getHead, readCheckpointsFile } = require('../storage/checkpoints');
|
|
1812
|
+
const { restoreGitState } = require('../storage/git');
|
|
1813
|
+
const { buildResumePrompt } = require('../prompts/resume');
|
|
1814
|
+
|
|
1815
|
+
let taskIdentifier = null;
|
|
1816
|
+
let fromCheckpointId = null;
|
|
1817
|
+
let gitRestore = false;
|
|
1818
|
+
let dryRun = false;
|
|
1819
|
+
|
|
1820
|
+
for (let i = 1; i < args.length; i++) {
|
|
1821
|
+
if (args[i] === '--from' || args[i] === '-f') {
|
|
1822
|
+
fromCheckpointId = args[++i];
|
|
1823
|
+
} else if (args[i] === '--git-restore' || args[i] === '--restore-git') {
|
|
1824
|
+
gitRestore = true;
|
|
1825
|
+
} else if (args[i] === '--dry-run' || args[i] === '-n') {
|
|
1826
|
+
dryRun = true;
|
|
1827
|
+
} else if (!args[i].startsWith('-')) {
|
|
1828
|
+
taskIdentifier = args[i];
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
if (!taskIdentifier) {
|
|
1833
|
+
console.log(`${c.yellow}Usage:${c.reset} agx resume [<project>:]<task>`);
|
|
1834
|
+
console.log(`${c.dim}Options:${c.reset}`);
|
|
1835
|
+
console.log(` --from <id> Resume from specific checkpoint (default: latest)`);
|
|
1836
|
+
console.log(` --git-restore Restore git state from checkpoint`);
|
|
1837
|
+
console.log(` --dry-run Show what would be resumed without executing`);
|
|
1838
|
+
process.exit(1);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Parse project:task or just task
|
|
1842
|
+
let projectSlug = null;
|
|
1843
|
+
let taskSlug = null;
|
|
1844
|
+
if (taskIdentifier.includes(':')) {
|
|
1845
|
+
[projectSlug, taskSlug] = taskIdentifier.split(':');
|
|
1846
|
+
} else {
|
|
1847
|
+
// Try to infer project from current directory or use default
|
|
1848
|
+
taskSlug = taskIdentifier;
|
|
1849
|
+
// Look for most recent project with this task
|
|
1850
|
+
const projectsDir = path.join(os.homedir(), '.agx', 'projects');
|
|
1851
|
+
if (fs.existsSync(projectsDir)) {
|
|
1852
|
+
const projects = fs.readdirSync(projectsDir).filter(p => {
|
|
1853
|
+
const taskDir = path.join(projectsDir, p, 'tasks', taskSlug);
|
|
1854
|
+
return fs.existsSync(taskDir);
|
|
1855
|
+
});
|
|
1856
|
+
if (projects.length === 1) {
|
|
1857
|
+
projectSlug = projects[0];
|
|
1858
|
+
} else if (projects.length > 1) {
|
|
1859
|
+
console.log(`${c.yellow}Multiple projects contain task '${taskSlug}':${c.reset}`);
|
|
1860
|
+
projects.forEach(p => console.log(` - ${p}:${taskSlug}`));
|
|
1861
|
+
console.log(`${c.dim}Specify project: agx resume <project>:${taskSlug}${c.reset}`);
|
|
1862
|
+
process.exit(1);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (!projectSlug || !taskSlug) {
|
|
1868
|
+
console.log(`${c.red}✗${c.reset} Could not resolve task '${taskIdentifier}'`);
|
|
1869
|
+
console.log(`${c.dim}Use format: agx resume <project>:<task>${c.reset}`);
|
|
1870
|
+
process.exit(1);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
try {
|
|
1874
|
+
// Load checkpoint
|
|
1875
|
+
let checkpoint = null;
|
|
1876
|
+
if (fromCheckpointId) {
|
|
1877
|
+
const checkpointsData = await readCheckpointsFile(projectSlug, taskSlug);
|
|
1878
|
+
checkpoint = checkpointsData.history.find(cp => cp.id === fromCheckpointId);
|
|
1879
|
+
if (!checkpoint) {
|
|
1880
|
+
console.log(`${c.red}✗${c.reset} Checkpoint '${fromCheckpointId}' not found`);
|
|
1881
|
+
const available = checkpointsData.history.slice(0, 5).map(cp => cp.id).join(', ');
|
|
1882
|
+
if (available) {
|
|
1883
|
+
console.log(`${c.dim}Available: ${available}${c.reset}`);
|
|
1884
|
+
}
|
|
1885
|
+
process.exit(1);
|
|
1886
|
+
}
|
|
1887
|
+
} else {
|
|
1888
|
+
checkpoint = await getHead(projectSlug, taskSlug);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
if (!checkpoint) {
|
|
1892
|
+
console.log(`${c.red}✗${c.reset} No checkpoints found for ${projectSlug}:${taskSlug}`);
|
|
1893
|
+
console.log(`${c.dim}Task must have emitted [checkpoint: ...] markers to resume${c.reset}`);
|
|
1894
|
+
process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Display checkpoint info
|
|
1898
|
+
console.log(`${c.bold}Resume from Checkpoint${c.reset}\n`);
|
|
1899
|
+
console.log(` ${c.dim}ID:${c.reset} ${checkpoint.id}`);
|
|
1900
|
+
console.log(` ${c.dim}Created:${c.reset} ${checkpoint.createdAt || 'unknown'}`);
|
|
1901
|
+
if (checkpoint.label) {
|
|
1902
|
+
console.log(` ${c.dim}Label:${c.reset} ${checkpoint.label}`);
|
|
1903
|
+
}
|
|
1904
|
+
if (checkpoint.iteration !== undefined) {
|
|
1905
|
+
console.log(` ${c.dim}Iter:${c.reset} ${checkpoint.iteration}`);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Show git state if present
|
|
1909
|
+
if (checkpoint.git) {
|
|
1910
|
+
const { sha, branch, dirty, patchFile } = checkpoint.git;
|
|
1911
|
+
console.log(`\n ${c.dim}Git:${c.reset} ${branch || 'detached'} @ ${(sha || '').slice(0, 7)}${dirty ? ' (dirty)' : ''}`);
|
|
1912
|
+
if (patchFile) {
|
|
1913
|
+
console.log(` ${c.dim}Patch:${c.reset} ${patchFile}`);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// Build resume prompt
|
|
1918
|
+
const resumePrompt = buildResumePrompt(checkpoint);
|
|
1919
|
+
console.log(`\n${c.bold}Resume Prompt${c.reset} (${resumePrompt.length} chars)\n`);
|
|
1920
|
+
console.log(c.dim + '─'.repeat(60) + c.reset);
|
|
1921
|
+
console.log(resumePrompt.slice(0, 1500) + (resumePrompt.length > 1500 ? '\n...(truncated)' : ''));
|
|
1922
|
+
console.log(c.dim + '─'.repeat(60) + c.reset);
|
|
1923
|
+
|
|
1924
|
+
if (dryRun) {
|
|
1925
|
+
console.log(`\n${c.yellow}Dry run - no execution${c.reset}`);
|
|
1926
|
+
process.exit(0);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Optionally restore git state
|
|
1930
|
+
if (gitRestore && checkpoint.git) {
|
|
1931
|
+
console.log(`\n${c.dim}Restoring git state...${c.reset}`);
|
|
1932
|
+
try {
|
|
1933
|
+
const cwd = process.cwd();
|
|
1934
|
+
restoreGitState(checkpoint.git, cwd, { force: false });
|
|
1935
|
+
console.log(`${c.green}✓${c.reset} Git state restored`);
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
console.log(`${c.yellow}⚠${c.reset} Git restore failed: ${err.message}`);
|
|
1938
|
+
console.log(`${c.dim}Continuing without git restore. Use --force if working tree has changes.${c.reset}`);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Continue execution with resume prompt
|
|
1943
|
+
if (!dryRun) {
|
|
1944
|
+
// Load task.json to get cloud task ID
|
|
1945
|
+
const storage = require('../storage');
|
|
1946
|
+
const taskJsonPath = path.join(storage.projectRoot(projectSlug), taskSlug, 'task.json');
|
|
1947
|
+
let cloudTaskId = null;
|
|
1948
|
+
|
|
1949
|
+
if (fs.existsSync(taskJsonPath)) {
|
|
1950
|
+
try {
|
|
1951
|
+
const taskData = JSON.parse(fs.readFileSync(taskJsonPath, 'utf8'));
|
|
1952
|
+
cloudTaskId = taskData?.cloud?.task_id;
|
|
1953
|
+
} catch {
|
|
1954
|
+
// Ignore parse errors
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
if (!cloudTaskId) {
|
|
1959
|
+
console.log(`${c.yellow}⚠${c.reset} No cloud task ID found. Running locally.`);
|
|
1960
|
+
// For now, just use the resume prompt with the default provider
|
|
1961
|
+
const provider = 'claude';
|
|
1962
|
+
console.log(`\n${c.dim}Executing with ${provider}...${c.reset}`);
|
|
1963
|
+
|
|
1964
|
+
try {
|
|
1965
|
+
const { executeTask } = require('../executor');
|
|
1966
|
+
await executeTask({
|
|
1967
|
+
taskId: `${projectSlug}:${taskSlug}`,
|
|
1968
|
+
title: checkpoint.objective || 'Resume task',
|
|
1969
|
+
content: resumePrompt,
|
|
1970
|
+
stage: 'execute',
|
|
1971
|
+
engine: provider,
|
|
1972
|
+
projectSlug,
|
|
1973
|
+
taskSlug,
|
|
1974
|
+
iteration: (checkpoint.iteration || 0) + 1,
|
|
1975
|
+
objective: checkpoint.objective,
|
|
1976
|
+
constraints: checkpoint.constraints,
|
|
1977
|
+
dontRepeat: checkpoint.dontRepeat,
|
|
1978
|
+
verifyFailures: checkpoint.verifyFailures || 0,
|
|
1979
|
+
plan: checkpoint.plan,
|
|
1980
|
+
criteria: checkpoint.criteria,
|
|
1981
|
+
onLog: (msg) => console.log(msg),
|
|
1982
|
+
onProgress: () => {},
|
|
1983
|
+
});
|
|
1984
|
+
console.log(`${c.green}✓${c.reset} Resume execution completed`);
|
|
1985
|
+
} catch (err) {
|
|
1986
|
+
console.log(`${c.red}✗${c.reset} Execution failed: ${err.message}`);
|
|
1987
|
+
process.exit(1);
|
|
1988
|
+
}
|
|
1989
|
+
} else {
|
|
1990
|
+
// Use cloud task flow
|
|
1991
|
+
console.log(`\n${c.dim}Resuming cloud task ${cloudTaskId.slice(0, 8)}...${c.reset}`);
|
|
1992
|
+
try {
|
|
1993
|
+
const exitCode = await runTaskInline(cloudTaskId, {
|
|
1994
|
+
resumePrompt,
|
|
1995
|
+
fromStage: 'execution',
|
|
1996
|
+
});
|
|
1997
|
+
process.exit(exitCode);
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
console.log(`${c.red}✗${c.reset} Resume failed: ${err.message}`);
|
|
2000
|
+
process.exit(1);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
process.exit(0);
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
console.log(`${c.red}✗${c.reset} Resume failed: ${err.message}`);
|
|
2008
|
+
if (process.env.DEBUG) {
|
|
2009
|
+
console.error(err);
|
|
2010
|
+
}
|
|
2011
|
+
process.exit(1);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1804
2015
|
// agx retry <taskId> [--task <id>] [--swarm] [--async]
|
|
1805
2016
|
if (cmd === 'retry' || (cmd === 'task' && args[1] === 'retry')) {
|
|
1806
2017
|
const runArgs = cmd === 'task' ? args.slice(1) : args;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud Sync - Sync local checkpoints to agx cloud API.
|
|
3
|
+
*
|
|
4
|
+
* Local-first with cloud sync:
|
|
5
|
+
* - Checkpoints are always saved locally first
|
|
6
|
+
* - Cloud sync happens asynchronously (best-effort)
|
|
7
|
+
* - Failures don't block local operations
|
|
8
|
+
*
|
|
9
|
+
* Sync strategy:
|
|
10
|
+
* - On checkpoint create: queue for cloud sync
|
|
11
|
+
* - Background worker processes queue
|
|
12
|
+
* - Retry with exponential backoff
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const SYNC_QUEUE_FILE = 'sync-queue.json';
|
|
19
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
20
|
+
const RETRY_DELAY_MS = 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load cloud config from environment or config file.
|
|
24
|
+
* @returns {{ apiUrl: string, token: string, userId: string } | null}
|
|
25
|
+
*/
|
|
26
|
+
function loadCloudConfig() {
|
|
27
|
+
// Try environment first
|
|
28
|
+
if (process.env.AGX_CLOUD_URL) {
|
|
29
|
+
return {
|
|
30
|
+
apiUrl: process.env.AGX_CLOUD_URL,
|
|
31
|
+
token: process.env.AGX_CLOUD_TOKEN || '',
|
|
32
|
+
userId: process.env.AGX_CLOUD_USER_ID || '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Try config file
|
|
37
|
+
try {
|
|
38
|
+
const configPath = path.join(process.env.HOME || '', '.agx', 'cloud-config.json');
|
|
39
|
+
if (fs.existsSync(configPath)) {
|
|
40
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sync a checkpoint to the cloud.
|
|
51
|
+
* @param {object} options
|
|
52
|
+
* @param {string} options.taskId - Cloud task ID
|
|
53
|
+
* @param {object} options.checkpoint - Checkpoint data
|
|
54
|
+
* @param {function} options.onLog - Logging callback
|
|
55
|
+
* @returns {Promise<{ success: boolean, error?: string }>}
|
|
56
|
+
*/
|
|
57
|
+
async function syncCheckpointToCloud({ taskId, checkpoint, onLog = () => {} }) {
|
|
58
|
+
const config = loadCloudConfig();
|
|
59
|
+
if (!config?.apiUrl) {
|
|
60
|
+
return { success: false, error: 'No cloud config' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!taskId) {
|
|
64
|
+
return { success: false, error: 'No task ID' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const url = `${config.apiUrl}/api/tasks/${taskId}/checkpoints`;
|
|
69
|
+
|
|
70
|
+
// Prepare checkpoint for cloud (remove large fields)
|
|
71
|
+
const cloudCheckpoint = {
|
|
72
|
+
id: checkpoint.id,
|
|
73
|
+
label: checkpoint.label,
|
|
74
|
+
createdAt: checkpoint.createdAt,
|
|
75
|
+
iteration: checkpoint.iteration,
|
|
76
|
+
objective: checkpoint.objective,
|
|
77
|
+
// Include plan summary, not full plan
|
|
78
|
+
planStepCount: checkpoint.plan?.steps?.length || 0,
|
|
79
|
+
currentStep: checkpoint.plan?.currentStep,
|
|
80
|
+
// Include criteria summary
|
|
81
|
+
criteriaCount: checkpoint.criteria?.length || 0,
|
|
82
|
+
criteriaPassed: checkpoint.criteria?.filter(c => c.passed)?.length || 0,
|
|
83
|
+
// Git info (no patch content)
|
|
84
|
+
gitSha: checkpoint.git?.sha,
|
|
85
|
+
gitBranch: checkpoint.git?.branch,
|
|
86
|
+
gitDirty: checkpoint.git?.dirty,
|
|
87
|
+
// Blocked info
|
|
88
|
+
blockedAt: checkpoint.blockedAt,
|
|
89
|
+
blockedReason: checkpoint.reason,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
...(config.token ? { 'Authorization': `Bearer ${config.token}` } : {}),
|
|
97
|
+
...(config.userId ? { 'x-user-id': config.userId } : {}),
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(cloudCheckpoint),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const error = `HTTP ${response.status}`;
|
|
104
|
+
onLog(`[cloud-sync] Failed to sync checkpoint: ${error}`);
|
|
105
|
+
return { success: false, error };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onLog(`[cloud-sync] Checkpoint ${checkpoint.id} synced`);
|
|
109
|
+
return { success: true };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const error = err?.message || String(err);
|
|
112
|
+
onLog(`[cloud-sync] Sync error: ${error}`);
|
|
113
|
+
return { success: false, error };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Queue a checkpoint for cloud sync.
|
|
119
|
+
* @param {string} taskRoot - Local task directory
|
|
120
|
+
* @param {object} checkpoint - Checkpoint data
|
|
121
|
+
* @param {string} cloudTaskId - Cloud task ID
|
|
122
|
+
*/
|
|
123
|
+
async function queueForSync(taskRoot, checkpoint, cloudTaskId) {
|
|
124
|
+
if (!taskRoot || !checkpoint || !cloudTaskId) return;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const queuePath = path.join(taskRoot, SYNC_QUEUE_FILE);
|
|
128
|
+
let queue = [];
|
|
129
|
+
|
|
130
|
+
if (fs.existsSync(queuePath)) {
|
|
131
|
+
try {
|
|
132
|
+
queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
133
|
+
} catch {
|
|
134
|
+
queue = [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
queue.push({
|
|
139
|
+
checkpointId: checkpoint.id,
|
|
140
|
+
cloudTaskId,
|
|
141
|
+
checkpoint,
|
|
142
|
+
queuedAt: new Date().toISOString(),
|
|
143
|
+
attempts: 0,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2));
|
|
147
|
+
} catch {
|
|
148
|
+
// Ignore queue errors
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Process the sync queue for a task.
|
|
154
|
+
* @param {string} taskRoot - Local task directory
|
|
155
|
+
* @param {function} onLog - Logging callback
|
|
156
|
+
* @returns {Promise<{ synced: number, failed: number }>}
|
|
157
|
+
*/
|
|
158
|
+
async function processSyncQueue(taskRoot, onLog = () => {}) {
|
|
159
|
+
const queuePath = path.join(taskRoot, SYNC_QUEUE_FILE);
|
|
160
|
+
if (!fs.existsSync(queuePath)) {
|
|
161
|
+
return { synced: 0, failed: 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let queue;
|
|
165
|
+
try {
|
|
166
|
+
queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
167
|
+
} catch {
|
|
168
|
+
return { synced: 0, failed: 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!Array.isArray(queue) || queue.length === 0) {
|
|
172
|
+
return { synced: 0, failed: 0 };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let synced = 0;
|
|
176
|
+
let failed = 0;
|
|
177
|
+
const remaining = [];
|
|
178
|
+
|
|
179
|
+
for (const item of queue) {
|
|
180
|
+
const result = await syncCheckpointToCloud({
|
|
181
|
+
taskId: item.cloudTaskId,
|
|
182
|
+
checkpoint: item.checkpoint,
|
|
183
|
+
onLog,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (result.success) {
|
|
187
|
+
synced++;
|
|
188
|
+
} else {
|
|
189
|
+
item.attempts++;
|
|
190
|
+
item.lastError = result.error;
|
|
191
|
+
item.lastAttempt = new Date().toISOString();
|
|
192
|
+
|
|
193
|
+
if (item.attempts < MAX_RETRY_ATTEMPTS) {
|
|
194
|
+
remaining.push(item);
|
|
195
|
+
} else {
|
|
196
|
+
failed++;
|
|
197
|
+
onLog(`[cloud-sync] Checkpoint ${item.checkpointId} failed after ${MAX_RETRY_ATTEMPTS} attempts`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Update queue with remaining items
|
|
203
|
+
if (remaining.length > 0) {
|
|
204
|
+
fs.writeFileSync(queuePath, JSON.stringify(remaining, null, 2));
|
|
205
|
+
} else {
|
|
206
|
+
try {
|
|
207
|
+
fs.unlinkSync(queuePath);
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { synced, failed };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create a cloud syncer that automatically syncs checkpoints.
|
|
218
|
+
* @param {object} options
|
|
219
|
+
* @param {string} options.taskRoot - Local task directory
|
|
220
|
+
* @param {string} options.cloudTaskId - Cloud task ID
|
|
221
|
+
* @param {function} options.onLog - Logging callback
|
|
222
|
+
* @returns {{ sync: function, processQueue: function }}
|
|
223
|
+
*/
|
|
224
|
+
function createCloudSyncer(options = {}) {
|
|
225
|
+
const { taskRoot, cloudTaskId, onLog = () => {} } = options;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
/**
|
|
229
|
+
* Sync a checkpoint immediately (best-effort).
|
|
230
|
+
*/
|
|
231
|
+
async sync(checkpoint) {
|
|
232
|
+
if (!cloudTaskId) {
|
|
233
|
+
// Queue for later if no cloud task ID yet
|
|
234
|
+
await queueForSync(taskRoot, checkpoint, cloudTaskId);
|
|
235
|
+
return { success: false, queued: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const result = await syncCheckpointToCloud({
|
|
239
|
+
taskId: cloudTaskId,
|
|
240
|
+
checkpoint,
|
|
241
|
+
onLog,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!result.success) {
|
|
245
|
+
// Queue for retry
|
|
246
|
+
await queueForSync(taskRoot, checkpoint, cloudTaskId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Process queued checkpoints.
|
|
254
|
+
*/
|
|
255
|
+
async processQueue() {
|
|
256
|
+
return processSyncQueue(taskRoot, onLog);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
loadCloudConfig,
|
|
263
|
+
syncCheckpointToCloud,
|
|
264
|
+
queueForSync,
|
|
265
|
+
processSyncQueue,
|
|
266
|
+
createCloudSyncer,
|
|
267
|
+
};
|