atris 3.2.0 → 3.11.0
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/GETTING_STARTED.md +65 -131
- package/README.md +18 -2
- package/atris/GETTING_STARTED.md +65 -131
- package/atris/PERSONA.md +5 -1
- package/atris/atris.md +122 -153
- package/atris/skills/aeo/SKILL.md +117 -0
- package/atris/skills/atris/SKILL.md +49 -25
- package/atris/skills/create-member/SKILL.md +29 -9
- package/atris/skills/endgame/SKILL.md +9 -0
- package/atris/skills/research-search/SKILL.md +167 -0
- package/atris/skills/research-search/arxiv_search.py +157 -0
- package/atris/skills/research-search/program.md +48 -0
- package/atris/skills/research-search/results.tsv +6 -0
- package/atris/skills/research-search/scholar_search.py +154 -0
- package/atris/skills/tidy/SKILL.md +36 -21
- package/atris/team/_template/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +35 -1
- package/atris.md +118 -178
- package/bin/atris.js +46 -12
- package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
- package/cli/atris_code.py +889 -0
- package/cli/runtime_guard.py +693 -0
- package/commands/align.js +16 -0
- package/commands/app.js +316 -0
- package/commands/autopilot.js +863 -23
- package/commands/brainstorm.js +7 -5
- package/commands/business.js +677 -2
- package/commands/clean.js +19 -3
- package/commands/computer.js +2022 -43
- package/commands/context-sync.js +5 -0
- package/commands/integrations.js +14 -9
- package/commands/lifecycle.js +12 -0
- package/commands/plugin.js +24 -0
- package/commands/pull.js +86 -11
- package/commands/push.js +153 -9
- package/commands/serve.js +1 -0
- package/commands/sync.js +272 -76
- package/commands/verify.js +50 -1
- package/commands/wiki.js +27 -2
- package/commands/workflow.js +24 -9
- package/lib/file-ops.js +13 -1
- package/lib/journal.js +23 -0
- package/lib/manifest.js +3 -0
- package/lib/scorecard.js +42 -4
- package/lib/sync-telemetry.js +59 -0
- package/lib/todo.js +6 -0
- package/lib/wiki.js +150 -6
- package/lib/workspace-safety.js +87 -0
- package/package.json +2 -1
- package/utils/api.js +19 -0
- package/utils/auth.js +25 -1
- package/utils/config.js +24 -0
- package/utils/update-check.js +16 -0
package/commands/context-sync.js
CHANGED
|
@@ -281,6 +281,11 @@ async function businessLog(slug) {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Convert ISO timestamp to human-readable relative time (e.g., "5m ago").
|
|
286
|
+
* @param {string} isoString - ISO 8601 timestamp
|
|
287
|
+
* @returns {string|null} Relative time string, or null if no input
|
|
288
|
+
*/
|
|
284
289
|
function _timeSince(isoString) {
|
|
285
290
|
if (!isoString) return null;
|
|
286
291
|
const diff = Date.now() - new Date(isoString).getTime();
|
package/commands/integrations.js
CHANGED
|
@@ -10,20 +10,25 @@
|
|
|
10
10
|
* atris slack channels - List Slack channels
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const { loadCredentials } = require('../utils/auth');
|
|
13
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
14
14
|
const { apiRequestJson } = require('../utils/api');
|
|
15
15
|
|
|
16
|
-
function getAuth() {
|
|
17
|
-
const
|
|
16
|
+
async function getAuth() {
|
|
17
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
18
|
+
const creds = ensured.error ? null : ensured.credentials;
|
|
18
19
|
if (!creds || !creds.token) {
|
|
19
|
-
|
|
20
|
+
if (ensured.error && ensured.error !== 'not_logged_in') {
|
|
21
|
+
console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
|
|
22
|
+
} else {
|
|
23
|
+
console.error('Not logged in. Run: atris login');
|
|
24
|
+
}
|
|
20
25
|
process.exit(1);
|
|
21
26
|
}
|
|
22
27
|
return { token: creds.token, email: creds.email || 'unknown' };
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
async function getAuthToken() {
|
|
26
|
-
return getAuth().token;
|
|
31
|
+
return (await getAuth()).token;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
// ============================================================================
|
|
@@ -31,7 +36,7 @@ async function getAuthToken() {
|
|
|
31
36
|
// ============================================================================
|
|
32
37
|
|
|
33
38
|
async function gmailInbox(options = {}) {
|
|
34
|
-
const { token, email } = getAuth();
|
|
39
|
+
const { token, email } = await getAuth();
|
|
35
40
|
const limit = options.limit || 10;
|
|
36
41
|
|
|
37
42
|
console.log('📬 Fetching inbox...\n');
|
|
@@ -129,7 +134,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
129
134
|
// ============================================================================
|
|
130
135
|
|
|
131
136
|
async function calendarToday() {
|
|
132
|
-
const { token, email } = getAuth();
|
|
137
|
+
const { token, email } = await getAuth();
|
|
133
138
|
|
|
134
139
|
console.log('📅 Today\'s events:\n');
|
|
135
140
|
|
|
@@ -224,7 +229,7 @@ async function twitterPost(text) {
|
|
|
224
229
|
|
|
225
230
|
if (!result.ok) {
|
|
226
231
|
if (result.status === 400 || result.status === 401) {
|
|
227
|
-
const { email } = getAuth();
|
|
232
|
+
const { email } = await getAuth();
|
|
228
233
|
console.error(`Twitter not connected for ${email}.`);
|
|
229
234
|
console.error('Connect at: https://atris.ai/dashboard/settings');
|
|
230
235
|
console.error(`Make sure you're signed in as ${email} on the web.`);
|
|
@@ -268,7 +273,7 @@ async function slackChannels() {
|
|
|
268
273
|
|
|
269
274
|
if (!result.ok) {
|
|
270
275
|
if (result.status === 400 || result.status === 401) {
|
|
271
|
-
const { email } = getAuth();
|
|
276
|
+
const { email } = await getAuth();
|
|
272
277
|
console.error(`Slack not connected for ${email}.`);
|
|
273
278
|
console.error('Connect at: https://atris.ai/dashboard/settings');
|
|
274
279
|
console.error(`Make sure you're signed in as ${email} on the web.`);
|
package/commands/lifecycle.js
CHANGED
|
@@ -3,6 +3,10 @@ const path = require('path');
|
|
|
3
3
|
const { loadCredentials } = require('../utils/auth');
|
|
4
4
|
const { apiRequestJson } = require('../utils/api');
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Resolve business slug from CLI arg or local .atris/business.json.
|
|
8
|
+
* @returns {string|null} The resolved slug, or null if not found.
|
|
9
|
+
*/
|
|
6
10
|
function resolveSlug() {
|
|
7
11
|
let slug = process.argv[3];
|
|
8
12
|
if (!slug || slug.startsWith('-')) {
|
|
@@ -18,6 +22,10 @@ function resolveSlug() {
|
|
|
18
22
|
return slug;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Pause a workspace to save compute (storage only mode).
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
21
29
|
async function sleepAtris() {
|
|
22
30
|
const slug = resolveSlug();
|
|
23
31
|
|
|
@@ -45,6 +53,10 @@ async function sleepAtris() {
|
|
|
45
53
|
console.log('Compute paused. Storage only — pennies/day.');
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Wake a sleeping workspace so agents resume automatically.
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
48
60
|
async function wakeAtris() {
|
|
49
61
|
const slug = resolveSlug();
|
|
50
62
|
|
package/commands/plugin.js
CHANGED
|
@@ -6,6 +6,11 @@ const { findAllSkills, parseFrontmatter } = require('./skill');
|
|
|
6
6
|
|
|
7
7
|
// --- Recursive Copy Helper ---
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Recursively copy a directory, skipping .DS_Store and .git.
|
|
11
|
+
* @param {string} src - Source directory path
|
|
12
|
+
* @param {string} dest - Destination directory path
|
|
13
|
+
*/
|
|
9
14
|
function copyRecursive(src, dest) {
|
|
10
15
|
fs.mkdirSync(dest, { recursive: true });
|
|
11
16
|
const entries = fs.readdirSync(src);
|
|
@@ -23,6 +28,12 @@ function copyRecursive(src, dest) {
|
|
|
23
28
|
|
|
24
29
|
// --- Generate plugin.json manifest ---
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Generate plugin.json manifest from discovered skills.
|
|
33
|
+
* @param {Array} skills - Array of skill objects
|
|
34
|
+
* @param {string} projectDir - Project root directory
|
|
35
|
+
* @returns {Object} Plugin manifest object
|
|
36
|
+
*/
|
|
26
37
|
function generateManifest(skills, projectDir) {
|
|
27
38
|
let pkg = {};
|
|
28
39
|
const pkgPath = path.join(projectDir, 'package.json');
|
|
@@ -44,6 +55,10 @@ function generateManifest(skills, projectDir) {
|
|
|
44
55
|
|
|
45
56
|
// --- Generate /atris-setup command ---
|
|
46
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Generate the /atris-setup skill markdown for workspace bootstrapping.
|
|
60
|
+
* @returns {string} Skill markdown content
|
|
61
|
+
*/
|
|
47
62
|
function generateSetupCommand() {
|
|
48
63
|
return `---
|
|
49
64
|
description: Set up Atris authentication and connect integrations (Gmail, Calendar, Slack, Notion, Drive)
|
|
@@ -113,6 +128,11 @@ Tell the user which integrations are connected and which still need setup. They
|
|
|
113
128
|
|
|
114
129
|
// --- Generate README.md ---
|
|
115
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Generate README.md for the plugin package.
|
|
133
|
+
* @param {Array} skills - Array of discovered skills
|
|
134
|
+
* @returns {string} README markdown content
|
|
135
|
+
*/
|
|
116
136
|
function generateREADME(skills) {
|
|
117
137
|
const skillList = skills.map(s => {
|
|
118
138
|
const fm = s.frontmatter || {};
|
|
@@ -149,6 +169,10 @@ ${skillList}
|
|
|
149
169
|
|
|
150
170
|
// --- BUILD subcommand ---
|
|
151
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Build the plugin package from atris/skills into dist/.
|
|
174
|
+
* @param {...string} args - CLI arguments
|
|
175
|
+
*/
|
|
152
176
|
function buildPlugin(...args) {
|
|
153
177
|
const projectDir = process.cwd();
|
|
154
178
|
const atrisDir = path.join(projectDir, 'atris');
|
package/commands/pull.js
CHANGED
|
@@ -9,6 +9,8 @@ const { parseJournalSections, mergeSections, reconstructJournal } = require('../
|
|
|
9
9
|
const { loadBusinesses } = require('./business');
|
|
10
10
|
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
|
|
11
11
|
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
12
|
+
const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
|
|
13
|
+
const { resolveSafeOutputDir } = require('../lib/workspace-safety');
|
|
12
14
|
|
|
13
15
|
function pruneEmptyParentDirs(filePath, stopDir) {
|
|
14
16
|
let current = path.dirname(filePath);
|
|
@@ -114,6 +116,7 @@ async function pullAtris() {
|
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
async function pullBusiness(slug) {
|
|
119
|
+
const elapsedMs = startTimer();
|
|
117
120
|
const creds = loadCredentials();
|
|
118
121
|
if (!creds || !creds.token) {
|
|
119
122
|
console.error('Not logged in. Run: atris login');
|
|
@@ -161,22 +164,44 @@ async function pullBusiness(slug) {
|
|
|
161
164
|
}
|
|
162
165
|
const timeoutMs = timeoutSec * 1000;
|
|
163
166
|
|
|
164
|
-
// Determine output directory
|
|
167
|
+
// Determine output directory.
|
|
168
|
+
//
|
|
169
|
+
// We only reuse the current working directory when we can prove it's the
|
|
170
|
+
// correct workspace for THIS business — i.e. it has a `.atris/business.json`
|
|
171
|
+
// whose slug matches `slug`. Any other signal (a stray `atris/` folder, a
|
|
172
|
+
// business.json for a different business, etc.) is NOT enough: pulling
|
|
173
|
+
// atris-labs-1 on top of a pallet workspace would mix two businesses into
|
|
174
|
+
// one directory and write pallet's manifest over atris-labs-1's (or vice
|
|
175
|
+
// versa), causing the next sync to do strange things.
|
|
176
|
+
//
|
|
177
|
+
// Fallback: create a fresh ./{slug}/ subdir. Always safe — even if cwd is
|
|
178
|
+
// $HOME or /tmp, we land in a dedicated subfolder.
|
|
165
179
|
const intoIdx = process.argv.indexOf('--into');
|
|
166
180
|
let outputDir;
|
|
167
181
|
if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
|
|
168
182
|
outputDir = path.resolve(process.argv[intoIdx + 1]);
|
|
169
|
-
} else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
|
|
170
|
-
// Inside a pulled workspace — pull into current dir (no nesting)
|
|
171
|
-
outputDir = process.cwd();
|
|
172
|
-
} else if (fs.existsSync(path.join(process.cwd(), 'atris')) && fs.statSync(path.join(process.cwd(), 'atris')).isDirectory()) {
|
|
173
|
-
// Inside an atris init'd workspace — merge business into current dir
|
|
174
|
-
outputDir = process.cwd();
|
|
175
183
|
} else {
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
185
|
+
let cwdMatchesSlug = false;
|
|
186
|
+
if (fs.existsSync(bizFile)) {
|
|
187
|
+
try {
|
|
188
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
189
|
+
const cwdSlug = biz.slug || biz.name;
|
|
190
|
+
cwdMatchesSlug = cwdSlug === slug;
|
|
191
|
+
} catch {
|
|
192
|
+
// Corrupt business.json — ignore, treat as no match.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
outputDir = cwdMatchesSlug ? process.cwd() : path.join(process.cwd(), slug);
|
|
178
196
|
}
|
|
179
197
|
|
|
198
|
+
// Auto-relocate if the resolved outputDir is dangerous ($HOME, /, /Users,
|
|
199
|
+
// system dirs, reserved home folders). Non-technical users shouldn't have
|
|
200
|
+
// to know about mkdir/cd — atris picks a safe subdir and tells them.
|
|
201
|
+
// Paired with the manifest-scoped sweep below, this makes it impossible
|
|
202
|
+
// for a stray cwd to cause atris to delete user files.
|
|
203
|
+
({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
|
|
204
|
+
|
|
180
205
|
// Resolve business ID — always refresh from API to avoid stale workspace_id
|
|
181
206
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
182
207
|
const businesses = loadBusinesses();
|
|
@@ -223,6 +248,11 @@ async function pullBusiness(slug) {
|
|
|
223
248
|
process.exit(1);
|
|
224
249
|
}
|
|
225
250
|
|
|
251
|
+
// Telemetry helper — captures wall-clock time including wake.
|
|
252
|
+
let _coldWake = false;
|
|
253
|
+
const emit = (outcome, extras = {}) =>
|
|
254
|
+
emitSyncEvent(creds.token, businessId, workspaceId, 'pull', outcome, elapsedMs(), extras);
|
|
255
|
+
|
|
226
256
|
// Auto-wake the EC2 computer if --auto-wake is set.
|
|
227
257
|
// Without this, pull silently serves stale data from agent_files cache when
|
|
228
258
|
// the computer is asleep — the bug that confused us all night.
|
|
@@ -232,6 +262,7 @@ async function pullBusiness(slug) {
|
|
|
232
262
|
const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
|
|
233
263
|
if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
|
|
234
264
|
process.stdout.write(' Waking EC2 computer... ');
|
|
265
|
+
_coldWake = true;
|
|
235
266
|
await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
|
|
236
267
|
const wakeStart = Date.now();
|
|
237
268
|
while (Date.now() - wakeStart < 90000) {
|
|
@@ -355,20 +386,30 @@ async function pullBusiness(slug) {
|
|
|
355
386
|
|
|
356
387
|
if (!result.ok) {
|
|
357
388
|
const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
|
|
389
|
+
let outcome = 'status_unknown';
|
|
358
390
|
if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
|
|
359
391
|
console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`);
|
|
392
|
+
outcome = 'hang';
|
|
360
393
|
} else if (result.status === 502) {
|
|
361
394
|
console.error(`\n Computer didn't respond in time. It may be waking up or the workspace is large.`);
|
|
362
395
|
console.error(` Try again in 30s, or use: atris pull ${slug} --only=team/,context/`);
|
|
396
|
+
outcome = 'cold_wake';
|
|
363
397
|
} else if (result.status === 409) {
|
|
364
398
|
console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
|
|
399
|
+
outcome = 'cold_wake';
|
|
365
400
|
} else if (result.status === 403) {
|
|
366
401
|
console.error(`\n Access denied. You're not a member of "${slug}".`);
|
|
402
|
+
outcome = 'access_denied';
|
|
367
403
|
} else if (result.status === 404) {
|
|
368
404
|
console.error(`\n Business "${slug}" not found.`);
|
|
405
|
+
outcome = 'status_unknown';
|
|
406
|
+
} else if (result.status === 429) {
|
|
407
|
+
console.error(`\n Pull failed: ${msg}`);
|
|
408
|
+
outcome = 'rate_limited';
|
|
369
409
|
} else {
|
|
370
410
|
console.error(`\n Pull failed: ${msg}`);
|
|
371
411
|
}
|
|
412
|
+
await emit(outcome, { error_detail: `${result.status}: ${msg}`.slice(0, 500) });
|
|
372
413
|
process.exit(1);
|
|
373
414
|
}
|
|
374
415
|
|
|
@@ -379,7 +420,10 @@ async function pullBusiness(slug) {
|
|
|
379
420
|
// mirror sweep so a genuinely-emptied cloud can clear local files. The
|
|
380
421
|
// sweep itself has a safety guard that refuses to wipe local content
|
|
381
422
|
// when remote reports empty (the snapshot-glitch case), so this is safe.
|
|
382
|
-
if (!force)
|
|
423
|
+
if (!force) {
|
|
424
|
+
await emit(_coldWake ? 'cold_wake' : 'success', { files_unchanged: 0 });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
383
427
|
} else {
|
|
384
428
|
console.log(` Processing ${files.length} files...`);
|
|
385
429
|
}
|
|
@@ -543,14 +587,27 @@ async function pullBusiness(slug) {
|
|
|
543
587
|
}
|
|
544
588
|
if (force) {
|
|
545
589
|
const remotePathSet = new Set(Object.keys(remoteFiles));
|
|
590
|
+
|
|
591
|
+
// SAFETY: only sweep files atris previously wrote (recorded in the
|
|
592
|
+
// manifest). Local-only files that were never on cloud are the user's
|
|
593
|
+
// own — atris didn't put them there, atris doesn't delete them. This
|
|
594
|
+
// makes it impossible for a stray cwd (e.g. $HOME) to cause atris to
|
|
595
|
+
// wipe ~/Library, ~/Downloads, etc.
|
|
596
|
+
//
|
|
597
|
+
// If there's no prior manifest (first pull), there's nothing to sweep —
|
|
598
|
+
// threeWayCompare already handled newRemote/conflicts/newLocal, and we
|
|
599
|
+
// have no basis for claiming ownership of any local-only file.
|
|
600
|
+
const managedPaths = manifest && manifest.files ? Object.keys(manifest.files) : [];
|
|
601
|
+
const sweepCandidates = managedPaths.filter(isInScope).filter((p) => localFiles[p]);
|
|
546
602
|
const inScopeLocal = Object.keys(localFiles).filter(isInScope);
|
|
603
|
+
|
|
547
604
|
if (remotePathSet.size === 0 && inScopeLocal.length > 0) {
|
|
548
605
|
console.log('');
|
|
549
606
|
console.log(' ⚠ Cloud reported zero files but local has in-scope content. Refusing to sweep.');
|
|
550
607
|
console.log(' This usually means the snapshot endpoint glitched. Try again,');
|
|
551
608
|
console.log(' or run `atris align --hard` if you really want to nuke local.');
|
|
552
609
|
} else {
|
|
553
|
-
for (const p of
|
|
610
|
+
for (const p of sweepCandidates) {
|
|
554
611
|
if (remotePathSet.has(p)) continue;
|
|
555
612
|
if (SERVER_HIDDEN_BASENAMES.has(basename(p))) continue;
|
|
556
613
|
const localPath = path.join(outputDir, p.replace(/^\//, ''));
|
|
@@ -702,6 +759,24 @@ async function pullBusiness(slug) {
|
|
|
702
759
|
}
|
|
703
760
|
}
|
|
704
761
|
|
|
762
|
+
// Telemetry — only count files where content actually came over the wire
|
|
763
|
+
// (smart-pull skips unchanged files and just sends a hash). Counting all
|
|
764
|
+
// remoteFiles here would dwarf the real signal once smart-pull steady-state
|
|
765
|
+
// is normal (< 1% of files transferred per pull).
|
|
766
|
+
let bytesTransferred = 0;
|
|
767
|
+
let filesTransferred = 0;
|
|
768
|
+
for (const filePath of Object.keys(remoteContent)) {
|
|
769
|
+
const f = remoteFiles[filePath];
|
|
770
|
+
if (f && typeof f.size === 'number') bytesTransferred += f.size;
|
|
771
|
+
filesTransferred++;
|
|
772
|
+
}
|
|
773
|
+
const totalRemote = Object.keys(remoteFiles).length;
|
|
774
|
+
await emit(_coldWake ? 'cold_wake' : 'success', {
|
|
775
|
+
files_pushed: filesTransferred,
|
|
776
|
+
files_unchanged: Math.max(0, totalRemote - filesTransferred),
|
|
777
|
+
bytes_transferred: bytesTransferred,
|
|
778
|
+
bytes_changed: bytesTransferred,
|
|
779
|
+
});
|
|
705
780
|
}
|
|
706
781
|
|
|
707
782
|
|
package/commands/push.js
CHANGED
|
@@ -6,9 +6,13 @@ const { apiRequestJson } = require('../utils/api');
|
|
|
6
6
|
const { loadBusinesses, saveBusinesses } = require('./business');
|
|
7
7
|
const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
|
|
8
8
|
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
9
|
+
const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
|
|
10
|
+
const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
|
|
9
11
|
|
|
10
12
|
async function pushAtris() {
|
|
13
|
+
const elapsedMs = startTimer();
|
|
11
14
|
let slug = process.argv[3];
|
|
15
|
+
let _coldWake = false;
|
|
12
16
|
|
|
13
17
|
// Auto-detect business from .atris/business.json in current dir
|
|
14
18
|
if (!slug || slug.startsWith('-')) {
|
|
@@ -80,6 +84,9 @@ async function pushAtris() {
|
|
|
80
84
|
|
|
81
85
|
if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
|
|
82
86
|
|
|
87
|
+
// Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
|
|
88
|
+
assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
|
|
89
|
+
|
|
83
90
|
// Resolve business — always refresh from API
|
|
84
91
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
85
92
|
const businesses = loadBusinesses();
|
|
@@ -105,6 +112,11 @@ async function pushAtris() {
|
|
|
105
112
|
|
|
106
113
|
if (!workspaceId) { console.error(`Business "${slug}" has no workspace.`); process.exit(1); }
|
|
107
114
|
|
|
115
|
+
// Telemetry helper — emits one event with the elapsed wall-clock time.
|
|
116
|
+
// Awaited (not fire-and-forget) because process.exit kills in-flight requests.
|
|
117
|
+
const emit = (outcome, extras = {}) =>
|
|
118
|
+
emitSyncEvent(creds.token, businessId, workspaceId, 'push', outcome, elapsedMs(), extras);
|
|
119
|
+
|
|
108
120
|
// Auto-wake the EC2 computer if --auto-wake is set, otherwise check status and warn.
|
|
109
121
|
// Without this, push silently routes to agent_files cache when computer is asleep
|
|
110
122
|
// (the silent fallback footgun from tonight's debugging).
|
|
@@ -114,6 +126,7 @@ async function pushAtris() {
|
|
|
114
126
|
const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
|
|
115
127
|
if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
|
|
116
128
|
process.stdout.write(' Waking EC2 computer... ');
|
|
129
|
+
_coldWake = true;
|
|
117
130
|
await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
|
|
118
131
|
const wakeStart = Date.now();
|
|
119
132
|
while (Date.now() - wakeStart < 90000) {
|
|
@@ -204,6 +217,7 @@ async function pushAtris() {
|
|
|
204
217
|
console.log('');
|
|
205
218
|
console.log(' Run `atris pull` first, then push your changes.');
|
|
206
219
|
console.log(' To override (force-push, may clobber cloud edits): atris push --force');
|
|
220
|
+
await emit('drift', { error_detail: `${driftFiles.length} file(s) drifted` });
|
|
207
221
|
process.exit(1);
|
|
208
222
|
}
|
|
209
223
|
console.log('fresh');
|
|
@@ -218,6 +232,7 @@ async function pushAtris() {
|
|
|
218
232
|
console.log(' ✗ Could not verify cloud freshness. Refusing to push.');
|
|
219
233
|
console.log(' The workspace may be unreachable or the snapshot endpoint is broken.');
|
|
220
234
|
console.log(' To bypass and force-push anyway: atris push --force');
|
|
235
|
+
await emit('status_unknown', { error_detail: `snapshot status ${snapshotResult.status || 'unknown'}` });
|
|
221
236
|
process.exit(1);
|
|
222
237
|
}
|
|
223
238
|
}
|
|
@@ -256,6 +271,7 @@ async function pushAtris() {
|
|
|
256
271
|
|
|
257
272
|
if (filesToPush.length === 0 && deletedPaths.length === 0) {
|
|
258
273
|
console.log('\n Already up to date.\n');
|
|
274
|
+
await emit('success', { files_unchanged: filteredLocalCount });
|
|
259
275
|
return;
|
|
260
276
|
}
|
|
261
277
|
|
|
@@ -280,13 +296,73 @@ async function pushAtris() {
|
|
|
280
296
|
let pushed = 0;
|
|
281
297
|
let deleted = 0;
|
|
282
298
|
let skipped = [];
|
|
299
|
+
// Files we sent that the server did not confirm as written/unchanged.
|
|
300
|
+
// The /sync endpoint can silently drop files (role-based filters on the
|
|
301
|
+
// business workspace route, path-rejection inside the warm runner, etc.)
|
|
302
|
+
// and still return HTTP 200. If we don't cross-check per-file results,
|
|
303
|
+
// the CLI prints "Pushed" for files that never actually landed — and
|
|
304
|
+
// the manifest records a hash that makes the next push skip them too,
|
|
305
|
+
// losing them permanently. `failedToLand` collects those casualties so
|
|
306
|
+
// we can warn the user AND keep them out of the manifest update.
|
|
307
|
+
let failedToLand = [];
|
|
308
|
+
const landedPaths = new Set();
|
|
283
309
|
let result = { ok: true };
|
|
284
310
|
|
|
311
|
+
// Server-canonical path format for the /sync endpoint: NO leading slash.
|
|
312
|
+
// The warm runner's _safe_path rejects `/atris/...` with "Absolute path
|
|
313
|
+
// outside workspace" (it only accepts paths under `/workspace/...`). All
|
|
314
|
+
// our internal bookkeeping uses a leading slash (manifest keys, localFiles
|
|
315
|
+
// keys, snapshot response paths), so we strip only at the wire.
|
|
316
|
+
const toWirePath = (p) => (p || '').replace(/^\/+/, '');
|
|
317
|
+
const fromWirePath = (p) => {
|
|
318
|
+
const s = String(p || '');
|
|
319
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
320
|
+
};
|
|
321
|
+
const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
|
|
322
|
+
|
|
323
|
+
// Inspect per-file results from a /sync response. Treat "written" and
|
|
324
|
+
// "unchanged" as success; everything else (including missing-from-results,
|
|
325
|
+
// which is how silent server-side drops look) is a failure.
|
|
326
|
+
const recordSyncResults = (sentFiles, response) => {
|
|
327
|
+
const resultsArr = response && response.data && Array.isArray(response.data.results)
|
|
328
|
+
? response.data.results
|
|
329
|
+
: null;
|
|
330
|
+
const seen = new Set();
|
|
331
|
+
if (resultsArr) {
|
|
332
|
+
for (const r of resultsArr) {
|
|
333
|
+
if (!r || !r.path) continue;
|
|
334
|
+
const status = String(r.status || '').toLowerCase();
|
|
335
|
+
const canonical = fromWirePath(r.path);
|
|
336
|
+
if (status === 'written' || status === 'unchanged') {
|
|
337
|
+
landedPaths.add(canonical);
|
|
338
|
+
seen.add(canonical);
|
|
339
|
+
} else {
|
|
340
|
+
failedToLand.push({ path: canonical, status: status || 'error', error: r.error || '' });
|
|
341
|
+
seen.add(canonical);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// No results array in response — old server. Best-effort: assume
|
|
346
|
+
// everything sent landed. This preserves existing behavior when
|
|
347
|
+
// talking to a server that doesn't return per-file status.
|
|
348
|
+
for (const f of sentFiles) {
|
|
349
|
+
landedPaths.add(f.path);
|
|
350
|
+
seen.add(f.path);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Any file we sent that the server didn't mention = silently dropped.
|
|
354
|
+
for (const f of sentFiles) {
|
|
355
|
+
if (!seen.has(f.path)) {
|
|
356
|
+
failedToLand.push({ path: f.path, status: 'dropped', error: 'server did not confirm write' });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
285
361
|
if (filesToPush.length > 0) {
|
|
286
|
-
// Push files to server
|
|
362
|
+
// Push files to server (strip leading slash — server requires workspace-relative paths)
|
|
287
363
|
result = await apiRequestJson(
|
|
288
364
|
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
289
|
-
{ method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
365
|
+
{ method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
290
366
|
);
|
|
291
367
|
|
|
292
368
|
if (!result.ok) {
|
|
@@ -298,27 +374,33 @@ async function pushAtris() {
|
|
|
298
374
|
if (allowed.length > 0) {
|
|
299
375
|
const retry = await apiRequestJson(
|
|
300
376
|
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
301
|
-
{ method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
377
|
+
{ method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
302
378
|
);
|
|
303
379
|
if (retry.ok) {
|
|
304
|
-
|
|
380
|
+
recordSyncResults(allowed, retry);
|
|
381
|
+
pushed = landedPaths.size;
|
|
305
382
|
} else {
|
|
306
383
|
console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
|
|
384
|
+
await emit('access_denied', { error_detail: `403 retry failed: ${retry.status}` });
|
|
307
385
|
process.exit(1);
|
|
308
386
|
}
|
|
309
387
|
} else {
|
|
310
388
|
console.error('\n Access denied: you can only push to your team/ folder.');
|
|
389
|
+
await emit('access_denied');
|
|
311
390
|
process.exit(1);
|
|
312
391
|
}
|
|
313
392
|
} else if (result.status === 409) {
|
|
314
393
|
console.error('\n Computer is sleeping. Wake it first.');
|
|
394
|
+
await emit('cold_wake', { error_detail: 'computer sleeping (409)' });
|
|
315
395
|
process.exit(1);
|
|
316
396
|
} else {
|
|
317
397
|
console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
|
|
398
|
+
await emit('status_unknown', { error_detail: `sync status ${result.status}` });
|
|
318
399
|
process.exit(1);
|
|
319
400
|
}
|
|
320
401
|
} else {
|
|
321
|
-
|
|
402
|
+
recordSyncResults(filesToPush, result);
|
|
403
|
+
pushed = landedPaths.size;
|
|
322
404
|
}
|
|
323
405
|
}
|
|
324
406
|
|
|
@@ -333,6 +415,7 @@ async function pushAtris() {
|
|
|
333
415
|
// - manifest update only counts confirmed-deleted paths
|
|
334
416
|
const deletedConfirmed = [];
|
|
335
417
|
const deleteFailed = [];
|
|
418
|
+
let _rateLimitedDeletes = 0;
|
|
336
419
|
for (let i = 0; i < deletedPaths.length; i++) {
|
|
337
420
|
const filePath = deletedPaths[i];
|
|
338
421
|
if (i > 0) {
|
|
@@ -345,6 +428,7 @@ async function pushAtris() {
|
|
|
345
428
|
);
|
|
346
429
|
if (deleteResult.status === 429) {
|
|
347
430
|
// Rate limit — wait 20s, retry once
|
|
431
|
+
_rateLimitedDeletes++;
|
|
348
432
|
await new Promise((r) => setTimeout(r, 20000));
|
|
349
433
|
deleteResult = await apiRequestJson(
|
|
350
434
|
`/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
|
|
@@ -369,10 +453,15 @@ async function pushAtris() {
|
|
|
369
453
|
if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
|
|
370
454
|
}
|
|
371
455
|
|
|
372
|
-
// Display results
|
|
456
|
+
// Display results — only for files the server confirmed as landed.
|
|
457
|
+
// A non-technical user seeing "+ atris/ideas/foo.md new file" naturally
|
|
458
|
+
// assumes foo.md is on cloud. So we only print that line when the server
|
|
459
|
+
// actually confirmed it via per-file status. Anything else goes into the
|
|
460
|
+
// loud failure block below.
|
|
373
461
|
console.log('');
|
|
374
462
|
for (const f of filesToPush) {
|
|
375
463
|
if (skipped.includes(f)) continue;
|
|
464
|
+
if (!landedPaths.has(f.path)) continue;
|
|
376
465
|
const isNew = !baseFiles[f.path];
|
|
377
466
|
console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
|
|
378
467
|
}
|
|
@@ -384,6 +473,26 @@ async function pushAtris() {
|
|
|
384
473
|
console.log(` x ${filePath.replace(/^\//, '')} deleted`);
|
|
385
474
|
}
|
|
386
475
|
|
|
476
|
+
// Loud failure block — files the server silently dropped or rejected.
|
|
477
|
+
// These did NOT land on cloud even though the HTTP call returned 200.
|
|
478
|
+
if (failedToLand.length > 0) {
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(` ⚠ ${failedToLand.length} file(s) did NOT land on cloud (server returned 200 but`);
|
|
481
|
+
console.log(` dropped or rejected these files):`);
|
|
482
|
+
const shown = failedToLand.slice(0, 15);
|
|
483
|
+
for (const f of shown) {
|
|
484
|
+
const detail = f.error ? ` — ${f.error}` : ` (${f.status})`;
|
|
485
|
+
console.log(` ✗ ${f.path.replace(/^\//, '')}${detail}`);
|
|
486
|
+
}
|
|
487
|
+
if (failedToLand.length > shown.length) {
|
|
488
|
+
console.log(` ... +${failedToLand.length - shown.length} more`);
|
|
489
|
+
}
|
|
490
|
+
console.log('');
|
|
491
|
+
console.log(' Common causes: path is outside the workspace (e.g. absolute /Users/... path),');
|
|
492
|
+
console.log(' your role lacks write permission for that folder, or warm runner returned an error.');
|
|
493
|
+
console.log(' These files will appear as drift on your next push so you can retry.');
|
|
494
|
+
}
|
|
495
|
+
|
|
387
496
|
// Summary
|
|
388
497
|
console.log('');
|
|
389
498
|
const parts = [];
|
|
@@ -396,16 +505,51 @@ async function pushAtris() {
|
|
|
396
505
|
|
|
397
506
|
// Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
|
|
398
507
|
// Failed deletes stay in the manifest so the next push will retry them.
|
|
508
|
+
//
|
|
509
|
+
// CRITICAL: only record manifest entries for files the server confirmed as
|
|
510
|
+
// landed (landedPaths). If we recorded the local hash for a file that the
|
|
511
|
+
// server silently dropped, the next push would compare local==manifest and
|
|
512
|
+
// skip it — the file would never land. Keeping it OUT of the manifest means
|
|
513
|
+
// the next push sees it as new/changed and retries automatically.
|
|
399
514
|
const updatedFiles = { ...baseFiles };
|
|
400
515
|
for (const f of filesToPush) {
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
516
|
+
if (skipped.includes(f)) continue;
|
|
517
|
+
if (!landedPaths.has(f.path)) continue;
|
|
518
|
+
updatedFiles[f.path] = localFiles[f.path];
|
|
404
519
|
}
|
|
405
520
|
for (const filePath of deletedConfirmed) {
|
|
406
521
|
delete updatedFiles[filePath];
|
|
407
522
|
}
|
|
408
523
|
saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
|
|
524
|
+
|
|
525
|
+
// Telemetry — outcome reflects actual run quality, not just exit-code-zero.
|
|
526
|
+
// Partial delete failures or rate-limit retries mean the run was NOT a clean win;
|
|
527
|
+
// labeling them success would poison the RL signal.
|
|
528
|
+
const bytesChanged = filesToPush.reduce((acc, f) => acc + (f.content ? Buffer.byteLength(f.content, 'utf8') : 0), 0);
|
|
529
|
+
let finalOutcome;
|
|
530
|
+
let finalDetail;
|
|
531
|
+
if (failedToLand.length > 0) {
|
|
532
|
+
finalOutcome = 'status_unknown';
|
|
533
|
+
finalDetail = `${failedToLand.length} file(s) silently dropped by server (statuses: ${[...new Set(failedToLand.map(f => f.status))].join(',')})`;
|
|
534
|
+
} else if (deleteFailed.length > 0) {
|
|
535
|
+
finalOutcome = 'status_unknown';
|
|
536
|
+
finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
|
|
537
|
+
} else if (_rateLimitedDeletes > 0) {
|
|
538
|
+
finalOutcome = 'rate_limited';
|
|
539
|
+
finalDetail = `${_rateLimitedDeletes} delete(s) hit 429 (recovered)`;
|
|
540
|
+
} else if (_coldWake) {
|
|
541
|
+
finalOutcome = 'cold_wake';
|
|
542
|
+
} else {
|
|
543
|
+
finalOutcome = 'success';
|
|
544
|
+
}
|
|
545
|
+
await emit(finalOutcome, {
|
|
546
|
+
files_pushed: pushed,
|
|
547
|
+
files_deleted: deleted,
|
|
548
|
+
files_unchanged: unchangedCount,
|
|
549
|
+
bytes_changed: bytesChanged,
|
|
550
|
+
bytes_transferred: bytesChanged,
|
|
551
|
+
error_detail: finalDetail,
|
|
552
|
+
});
|
|
409
553
|
}
|
|
410
554
|
|
|
411
555
|
module.exports = { pushAtris };
|