atris 3.61.0 → 3.63.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/README.md +37 -5
- package/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +12 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/buy.js +242 -0
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +68 -14
- package/commands/mission.js +116 -4
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/task.js +7 -0
- package/commands/team.js +82 -10
- package/commands/transactions.js +115 -0
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +58 -26
- package/lib/commerce.js +75 -0
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +300 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +25 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +39 -0
- package/lib/member-type.js +27 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/runner-command.js +37 -2
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +169 -23
- package/scripts/det/ytquote-repair.js +18 -4
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/lib/runner-command.js
CHANGED
|
@@ -38,10 +38,12 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
38
38
|
efforts: CLAUDE_EFFORTS,
|
|
39
39
|
}),
|
|
40
40
|
// No -m or effort unless the roster pins one: codex then reads its own
|
|
41
|
-
// ~/.codex/config.toml, which is what the roster view reports.
|
|
41
|
+
// ~/.codex/config.toml, which is what the roster view reports. Codex runs
|
|
42
|
+
// only OpenAI models, so a claude model name never reaches it.
|
|
42
43
|
codex: Object.freeze({
|
|
43
44
|
bin: 'codex',
|
|
44
45
|
model: '',
|
|
46
|
+
ownModelsOnly: true,
|
|
45
47
|
commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
|
|
46
48
|
effortFlag: '-c model_reasoning_effort=',
|
|
47
49
|
efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
|
|
@@ -211,6 +213,19 @@ function resolvePinnedRunnerModel(mission) {
|
|
|
211
213
|
return explicit || runnerProfileValue('model');
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
// The model an engine is launched with. An engine that runs only its own
|
|
217
|
+
// vendor's models drops a claude model name (a mission's stored default, a
|
|
218
|
+
// claude env pin) and rides its own default instead.
|
|
219
|
+
function runnerModelFor(engineId, model) {
|
|
220
|
+
const value = String(model || '').trim();
|
|
221
|
+
const name = String(engineId || '').trim().toLowerCase();
|
|
222
|
+
const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
|
|
223
|
+
if (!value || !def || !def.ownModelsOnly) return value;
|
|
224
|
+
// Required here, not at the top: engine-registry requires this file.
|
|
225
|
+
const { isClaudeFamilyModel } = require('./engine-registry');
|
|
226
|
+
return isClaudeFamilyModel(value) ? '' : value;
|
|
227
|
+
}
|
|
228
|
+
|
|
214
229
|
function resolveClaudeRunnerBin() {
|
|
215
230
|
const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
|
|
216
231
|
if (env) return env;
|
|
@@ -265,6 +280,14 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
|
|
|
265
280
|
// The pinned flags take their leading space with them, so an unpinned
|
|
266
281
|
// launch reads exactly like the template without the placeholders.
|
|
267
282
|
template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
|
|
283
|
+
// A model dropped for an engine that cannot run it leaves no gap either,
|
|
284
|
+
// and a literal "--model {model}", "-m{model}" or a quoted "{model}" goes
|
|
285
|
+
// with it, quotes and all.
|
|
286
|
+
if (!model) {
|
|
287
|
+
template = template
|
|
288
|
+
.replace(/\s*\{modelFlag\}/g, '')
|
|
289
|
+
.replace(/\s*(?:--model|-m)(?:\s+|=)?(["']?)\{model\}\1/g, '');
|
|
290
|
+
}
|
|
268
291
|
// Checked even when the template has no slot, so an engine that cannot
|
|
269
292
|
// take effort refuses instead of running at its default.
|
|
270
293
|
const effortFlag = runnerEffortFlag(effort);
|
|
@@ -299,7 +322,18 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
|
|
|
299
322
|
const resolved = resolveClaudeRunnerModel({ model });
|
|
300
323
|
const template = resolveClaudeRunnerCommandTemplate();
|
|
301
324
|
if (template) {
|
|
302
|
-
|
|
325
|
+
// Both the profile and the program that actually runs count: a runner
|
|
326
|
+
// bin named codex (or a path ending in it) is codex under any profile.
|
|
327
|
+
const profile = resolveRunnerProfileName();
|
|
328
|
+
const binName = String(resolveClaudeRunnerBin()).split(/[\\/]/).pop();
|
|
329
|
+
const modelFor = (value) => runnerModelFor(binName, runnerModelFor(profile, value));
|
|
330
|
+
return renderRunnerCommandTemplate(template, {
|
|
331
|
+
promptFile,
|
|
332
|
+
allowedTools,
|
|
333
|
+
model: modelFor(resolved),
|
|
334
|
+
pinnedModel: modelFor(resolvePinnedRunnerModel({ model })),
|
|
335
|
+
effort,
|
|
336
|
+
});
|
|
303
337
|
}
|
|
304
338
|
const safePath = String(promptFile).replace(/'/g, "'\\''");
|
|
305
339
|
let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
|
|
@@ -339,6 +373,7 @@ module.exports = {
|
|
|
339
373
|
resolveRunnerProfile,
|
|
340
374
|
resolveClaudeRunnerModel,
|
|
341
375
|
resolvePinnedRunnerModel,
|
|
376
|
+
runnerModelFor,
|
|
342
377
|
resolveClaudeRunnerBin,
|
|
343
378
|
resolveClaudeRunnerCommandTemplate,
|
|
344
379
|
buildRunnerAvailabilityCommand,
|
package/lib/self-drive.js
CHANGED
|
@@ -141,6 +141,8 @@ function defaultDependencies() {
|
|
|
141
141
|
taskDb,
|
|
142
142
|
resolveEngineForRole: require('./engine-registry').resolveEngineForRole,
|
|
143
143
|
dispatchToEngine: require('./fleet').dispatchToEngine,
|
|
144
|
+
recordDispatchOutcome: require('./fleet').recordDispatchOutcome,
|
|
145
|
+
clock: Date.now,
|
|
144
146
|
createAgentWorktree: require('../commands/worktree').createAgentWorktree,
|
|
145
147
|
};
|
|
146
148
|
}
|
|
@@ -272,7 +274,13 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
|
|
|
272
274
|
agent: engine.id,
|
|
273
275
|
task: `mission-blocker-${String(ref).toLowerCase()}`,
|
|
274
276
|
});
|
|
275
|
-
|
|
277
|
+
const pin = {
|
|
278
|
+
model: engine.roster_model || '',
|
|
279
|
+
effort: engine.roster_effort || '',
|
|
280
|
+
maxSeconds: Number(engine.roster_max_seconds) || 0,
|
|
281
|
+
};
|
|
282
|
+
const startedMs = deps.clock();
|
|
283
|
+
const dispatched = deps.dispatchToEngine({
|
|
276
284
|
task,
|
|
277
285
|
engine: engine.id,
|
|
278
286
|
worktreePath: worktree.path,
|
|
@@ -281,14 +289,30 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
|
|
|
281
289
|
...(engine.roster_effort ? { effort: engine.roster_effort } : {}),
|
|
282
290
|
...(engine.roster_max_seconds ? { maxSeconds: engine.roster_max_seconds } : {}),
|
|
283
291
|
});
|
|
292
|
+
// How the engine did goes through the same health and run-record calls
|
|
293
|
+
// as every other dispatch, so a stalled blocker build benches it.
|
|
294
|
+
const run = dispatched && typeof dispatched === 'object' && typeof dispatched.then !== 'function'
|
|
295
|
+
? deps.recordDispatchOutcome(scopedRoot, {
|
|
296
|
+
engine: engine.id,
|
|
297
|
+
pin,
|
|
298
|
+
startedMs,
|
|
299
|
+
endedMs: deps.clock(),
|
|
300
|
+
result: dispatched,
|
|
301
|
+
job: 'build',
|
|
302
|
+
task: ref,
|
|
303
|
+
source: 'mission blocker',
|
|
304
|
+
member: mission.owner || '',
|
|
305
|
+
})
|
|
306
|
+
: null;
|
|
284
307
|
projectTasks(deps.taskDb, db, scopedRoot);
|
|
285
308
|
appendEvent?.('mission_blocker_dispatched', {
|
|
286
309
|
task_id: ref,
|
|
287
310
|
engine: engine.id,
|
|
288
311
|
blocker_class: blockerClass,
|
|
289
312
|
worktree_path: worktree.path,
|
|
313
|
+
...(run ? { outcome: run.outcome } : {}),
|
|
290
314
|
});
|
|
291
|
-
return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched' };
|
|
315
|
+
return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched', ...(run ? { outcome: run.outcome, ...(run.detail ? { detail: run.detail } : {}) } : {}) };
|
|
292
316
|
} catch (error) {
|
|
293
317
|
return { taskId: ref, dispatched: false, engine: engine.id, reason: error.message || String(error) };
|
|
294
318
|
}
|
package/lib/state-detection.js
CHANGED
|
@@ -131,7 +131,7 @@ function getTasksFromDbBucket(atrisDir, bucketName) {
|
|
|
131
131
|
const taskDb = require('./task-db');
|
|
132
132
|
if (!fs.existsSync(taskDb.getDbPath())) return null;
|
|
133
133
|
const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
|
|
134
|
-
const rows = taskDb.
|
|
134
|
+
const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
|
|
135
135
|
if (!rows.length) return null;
|
|
136
136
|
return rows
|
|
137
137
|
.filter(row => statuses.has(String(row.status || '').toLowerCase()))
|
|
@@ -151,7 +151,7 @@ function getTaskCounts(atrisDir) {
|
|
|
151
151
|
const taskDb = require('./task-db');
|
|
152
152
|
if (fs.existsSync(taskDb.getDbPath())) {
|
|
153
153
|
const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
|
|
154
|
-
const rows = taskDb.
|
|
154
|
+
const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
|
|
155
155
|
if (rows.length) {
|
|
156
156
|
const counts = { backlog: 0, active: 0, review: 0, reviewCertified: 0, source: 'db' };
|
|
157
157
|
for (const row of rows) {
|
|
@@ -190,7 +190,7 @@ function getTaskGlance(atrisDir, sampleSize = 3) {
|
|
|
190
190
|
const taskDb = require('./task-db');
|
|
191
191
|
if (fs.existsSync(taskDb.getDbPath())) {
|
|
192
192
|
const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
|
|
193
|
-
const rows = taskDb.
|
|
193
|
+
const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
|
|
194
194
|
if (rows.length) {
|
|
195
195
|
const glance = {
|
|
196
196
|
backlog: 0, active: 0, review: 0, reviewCertified: 0,
|
package/lib/task-db.js
CHANGED
|
@@ -333,10 +333,14 @@ function taskDisplayRef(row, index) {
|
|
|
333
333
|
return `${workspaceRefPrefix(row && row.workspace_root)}-${Number(index) + 1}`;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
// normalizedIds: the ids already passed through normalizeTaskRef, so a caller
|
|
337
|
+
// resolving every row of a list normalizes the list once, not once per row.
|
|
338
|
+
function shortestUniqueTaskRef(id, ids, minLength = 8, normalizedIds = null) {
|
|
337
339
|
const normalized = normalizeTaskRef(id);
|
|
338
340
|
if (!normalized) return '';
|
|
339
|
-
const all =
|
|
341
|
+
const all = Array.isArray(normalizedIds)
|
|
342
|
+
? normalizedIds
|
|
343
|
+
: (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
|
|
340
344
|
for (let length = Math.min(minLength, normalized.length); length <= normalized.length; length += 1) {
|
|
341
345
|
const prefix = normalized.slice(0, length);
|
|
342
346
|
const matches = all.filter(candidate => candidate.startsWith(prefix));
|
|
@@ -364,10 +368,11 @@ function withTaskDisplayRefs(rows, refRows = rows) {
|
|
|
364
368
|
const sorted = [...group]
|
|
365
369
|
.sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id || '').localeCompare(String(b.id || '')))
|
|
366
370
|
const ids = sorted.map(row => row && row.id);
|
|
371
|
+
const normalizedIds = ids.map(normalizeTaskRef).filter(Boolean);
|
|
367
372
|
sorted.forEach((row, index) => {
|
|
368
373
|
refs.set(row.id, {
|
|
369
374
|
display_id: row.display_id || taskDisplayRef(row, index),
|
|
370
|
-
legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8),
|
|
375
|
+
legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8, normalizedIds),
|
|
371
376
|
});
|
|
372
377
|
});
|
|
373
378
|
}
|
|
@@ -470,6 +475,35 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
|
|
|
470
475
|
}));
|
|
471
476
|
}
|
|
472
477
|
|
|
478
|
+
// Boot reads the same task list from several places (the TODO buckets and
|
|
479
|
+
// the status glance). Share one read per connection while nothing changed:
|
|
480
|
+
// PRAGMA data_version moves when another connection commits, and
|
|
481
|
+
// total_changes() moves when this connection writes. Callers must treat the
|
|
482
|
+
// rows as read-only.
|
|
483
|
+
const listTasksMemo = new WeakMap();
|
|
484
|
+
|
|
485
|
+
function listTasksCached(db, opts = {}) {
|
|
486
|
+
let stamp;
|
|
487
|
+
try {
|
|
488
|
+
const version = db.prepare('PRAGMA data_version').get();
|
|
489
|
+
const changes = db.prepare('SELECT total_changes() AS n').get();
|
|
490
|
+
stamp = `${Object.values(version || {})[0]}:${changes && changes.n}`;
|
|
491
|
+
} catch {
|
|
492
|
+
return listTasks(db, opts);
|
|
493
|
+
}
|
|
494
|
+
const key = JSON.stringify([opts.workspaceRoot || '', opts.status || '', opts.claimedBy || '', opts.limit || 0]);
|
|
495
|
+
let byKey = listTasksMemo.get(db);
|
|
496
|
+
if (!byKey) {
|
|
497
|
+
byKey = new Map();
|
|
498
|
+
listTasksMemo.set(db, byKey);
|
|
499
|
+
}
|
|
500
|
+
const hit = byKey.get(key);
|
|
501
|
+
if (hit && hit.stamp === stamp) return hit.rows;
|
|
502
|
+
const rows = listTasks(db, opts);
|
|
503
|
+
byKey.set(key, { stamp, rows });
|
|
504
|
+
return rows;
|
|
505
|
+
}
|
|
506
|
+
|
|
473
507
|
function getTask(db, id) {
|
|
474
508
|
if (!id) throw new Error('id required');
|
|
475
509
|
const row = db.prepare(`
|
|
@@ -2243,6 +2277,7 @@ module.exports = {
|
|
|
2243
2277
|
addTask,
|
|
2244
2278
|
getTask,
|
|
2245
2279
|
listTasks,
|
|
2280
|
+
listTasksCached,
|
|
2246
2281
|
claimTask,
|
|
2247
2282
|
releaseTask,
|
|
2248
2283
|
reopenTask,
|
package/lib/team-lineup.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { engineRunsView, modelLabel } = require('./roster-models');
|
|
8
8
|
const { rosterToolLabel } = require('./engine-registry');
|
|
9
|
+
const { suggestionCount } = require('./roster-suggest');
|
|
9
10
|
|
|
10
11
|
const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
|
|
11
12
|
const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
|
|
@@ -51,6 +52,8 @@ function readLineupSafe(root = process.cwd(), now = new Date()) {
|
|
|
51
52
|
|
|
52
53
|
// One boot line: the tool and model that runs each built-in job right now,
|
|
53
54
|
// custom jobs only when all of them still fit. '' when nothing resolves.
|
|
55
|
+
// When the run record suggests a new order for any job, a short marker
|
|
56
|
+
// points at the roster view; only the end of the record is read.
|
|
54
57
|
function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
|
|
55
58
|
let rows = jobs;
|
|
56
59
|
if (!rows) {
|
|
@@ -65,9 +68,11 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
|
|
|
65
68
|
const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
|
|
66
69
|
const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
|
|
67
70
|
if (!builtIn.length && !custom.length) return '';
|
|
71
|
+
const count = suggestionCount(ordered, root, { now });
|
|
72
|
+
const marker = count ? ` · ${count} suggestion${count === 1 ? '' : 's'} (atris engine roster)` : '';
|
|
68
73
|
const all = [...builtIn, ...custom].join(' · ');
|
|
69
|
-
if (all.length <= width || !builtIn.length) return all
|
|
70
|
-
return builtIn.join(' · ')
|
|
74
|
+
if (all.length + marker.length <= width || !builtIn.length) return `${all}${marker}`;
|
|
75
|
+
return `${builtIn.join(' · ')}${marker}`;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
function runsText(view) {
|
package/lib/todo.js
CHANGED
|
@@ -41,7 +41,7 @@ function dbToShimRow(row) {
|
|
|
41
41
|
function dbBuckets(workspaceRoot) {
|
|
42
42
|
const taskDb = require('./task-db');
|
|
43
43
|
const db = taskDb.open();
|
|
44
|
-
const rows = taskDb.withTaskDisplayRefs(taskDb.
|
|
44
|
+
const rows = taskDb.withTaskDisplayRefs(taskDb.listTasksCached(db, { workspaceRoot, limit: 500 }));
|
|
45
45
|
// Need raw source_key for merge dedup, plus the shim shape for callers.
|
|
46
46
|
const backlog = [];
|
|
47
47
|
const inProgress = [];
|
package/lib/wish-audit.js
CHANGED
|
@@ -424,7 +424,7 @@ function policyValidator(root, excludeId = '') {
|
|
|
424
424
|
.filter((engine) => engine.id !== String(excludeId || ''))
|
|
425
425
|
.sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order)
|
|
426
426
|
|| String(a.id).localeCompare(String(b.id)));
|
|
427
|
-
return ranked.find((engine) =>
|
|
427
|
+
return ranked.find((engine) => engineRegistry.engineReadyAt(engine)) || ranked[0] || null;
|
|
428
428
|
} catch {
|
|
429
429
|
return null;
|
|
430
430
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/cpu-hotspots.js - where does an atris command spend its time?
|
|
3
|
+
// Runs one command under `node --cpu-prof` in a temp profile dir, then prints
|
|
4
|
+
// the total profiled ms, the top self-time hotspots, and the top call chains
|
|
5
|
+
// that end in spawnSync (application frames only, so node internals and
|
|
6
|
+
// node_modules drop out). Optional plain wall-clock runs give a median.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node scripts/det/cpu-hotspots.js [options] -- <script.js> [args...]
|
|
10
|
+
// node scripts/det/cpu-hotspots.js --cwd ../repo -- bin/atris.js atris.md
|
|
11
|
+
// node scripts/det/cpu-hotspots.js --wall 5 --top 15 -- bin/atris.js team
|
|
12
|
+
//
|
|
13
|
+
// Options:
|
|
14
|
+
// --cwd <dir> working directory for the profiled command (default: here)
|
|
15
|
+
// --top <n> rows per table (default 10)
|
|
16
|
+
// --wall <n> also time n unprofiled runs and print the median wall ms
|
|
17
|
+
// --app <dir> frames under this dir count as application frames
|
|
18
|
+
// (default: the repo that holds this script)
|
|
19
|
+
// --keep keep the temp profile dir and print its path
|
|
20
|
+
// --json structured output
|
|
21
|
+
//
|
|
22
|
+
// The command's own stdout is discarded; its exit code is reported.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_APP_ROOT = path.resolve(__dirname, '..', '..');
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const opts = { cwd: process.cwd(), top: 10, wall: 0, app: DEFAULT_APP_ROOT, keep: false, json: false, command: [] };
|
|
35
|
+
const sep = argv.indexOf('--');
|
|
36
|
+
const head = sep === -1 ? argv : argv.slice(0, sep);
|
|
37
|
+
opts.command = sep === -1 ? [] : argv.slice(sep + 1);
|
|
38
|
+
for (let i = 0; i < head.length; i += 1) {
|
|
39
|
+
const arg = head[i];
|
|
40
|
+
if (arg === '--cwd') opts.cwd = path.resolve(head[++i] || '.');
|
|
41
|
+
else if (arg === '--top') opts.top = Math.max(1, Number(head[++i]) || 10);
|
|
42
|
+
else if (arg === '--wall') opts.wall = Math.max(0, Number(head[++i]) || 0);
|
|
43
|
+
else if (arg === '--app') opts.app = path.resolve(head[++i] || '.');
|
|
44
|
+
else if (arg === '--keep') opts.keep = true;
|
|
45
|
+
else if (arg === '--json') opts.json = true;
|
|
46
|
+
else if (arg === '-h' || arg === '--help') opts.help = true;
|
|
47
|
+
else opts.command.push(arg);
|
|
48
|
+
}
|
|
49
|
+
return opts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function frameFile(url) {
|
|
53
|
+
return String(url || '').replace(/^file:\/\//, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAppFrame(frame, appRoot) {
|
|
57
|
+
const file = frameFile(frame.url);
|
|
58
|
+
if (!file || !file.startsWith(appRoot)) return false;
|
|
59
|
+
return !file.includes(`${path.sep}node_modules${path.sep}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function frameLabel(frame, appRoot) {
|
|
63
|
+
const file = frameFile(frame.url);
|
|
64
|
+
const rel = appRoot && file.startsWith(appRoot) ? path.relative(appRoot, file) : (file || '(native)');
|
|
65
|
+
const name = frame.functionName || '(anonymous)';
|
|
66
|
+
return `${name} ${rel}:${Number(frame.lineNumber) + 1}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pure core: a parsed .cpuprofile in, tables out. Unit-testable.
|
|
70
|
+
function analyzeProfile(profile, { appRoot = DEFAULT_APP_ROOT, top = 10 } = {}) {
|
|
71
|
+
const nodes = new Map();
|
|
72
|
+
const parent = new Map();
|
|
73
|
+
for (const node of profile.nodes || []) {
|
|
74
|
+
nodes.set(node.id, node);
|
|
75
|
+
for (const child of node.children || []) parent.set(child, node.id);
|
|
76
|
+
}
|
|
77
|
+
const selfUs = new Map();
|
|
78
|
+
const samples = profile.samples || [];
|
|
79
|
+
const deltas = profile.timeDeltas || [];
|
|
80
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
81
|
+
const dt = Math.max(0, Number(deltas[i]) || 0);
|
|
82
|
+
selfUs.set(samples[i], (selfUs.get(samples[i]) || 0) + dt);
|
|
83
|
+
}
|
|
84
|
+
const totalUs = [...selfUs.values()].reduce((a, b) => a + b, 0)
|
|
85
|
+
|| Math.max(0, Number(profile.endTime) - Number(profile.startTime)) || 0;
|
|
86
|
+
|
|
87
|
+
const byFrame = new Map();
|
|
88
|
+
for (const [id, us] of selfUs) {
|
|
89
|
+
const node = nodes.get(id);
|
|
90
|
+
if (!node) continue;
|
|
91
|
+
const label = frameLabel(node.callFrame || {}, appRoot);
|
|
92
|
+
byFrame.set(label, (byFrame.get(label) || 0) + us);
|
|
93
|
+
}
|
|
94
|
+
const hotspots = [...byFrame.entries()]
|
|
95
|
+
.sort((a, b) => b[1] - a[1])
|
|
96
|
+
.slice(0, top)
|
|
97
|
+
.map(([frame, us]) => ({ frame, ms: Math.round(us / 1000) }));
|
|
98
|
+
|
|
99
|
+
// Time under each spawnSync node = self time of every descendant sample.
|
|
100
|
+
const subtreeUs = new Map();
|
|
101
|
+
for (const [id, us] of selfUs) {
|
|
102
|
+
let cur = id;
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
while (cur !== undefined && !seen.has(cur)) {
|
|
105
|
+
seen.add(cur);
|
|
106
|
+
subtreeUs.set(cur, (subtreeUs.get(cur) || 0) + us);
|
|
107
|
+
cur = parent.get(cur);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const chains = new Map();
|
|
111
|
+
for (const node of nodes.values()) {
|
|
112
|
+
const fn = node.callFrame && node.callFrame.functionName;
|
|
113
|
+
if (fn !== 'spawnSync') continue;
|
|
114
|
+
// Only the outermost spawnSync on a path counts (child_process wraps an
|
|
115
|
+
// internal spawnSync of the same name).
|
|
116
|
+
let up = parent.get(node.id);
|
|
117
|
+
let nested = false;
|
|
118
|
+
while (up !== undefined) {
|
|
119
|
+
const upNode = nodes.get(up);
|
|
120
|
+
if (upNode && upNode.callFrame && upNode.callFrame.functionName === 'spawnSync') { nested = true; break; }
|
|
121
|
+
up = parent.get(up);
|
|
122
|
+
}
|
|
123
|
+
if (nested) continue;
|
|
124
|
+
const us = subtreeUs.get(node.id) || 0;
|
|
125
|
+
if (!us) continue;
|
|
126
|
+
const frames = [];
|
|
127
|
+
let cur = parent.get(node.id);
|
|
128
|
+
while (cur !== undefined) {
|
|
129
|
+
const n = nodes.get(cur);
|
|
130
|
+
if (n && n.callFrame && isAppFrame(n.callFrame, appRoot)) frames.push(frameLabel(n.callFrame, appRoot));
|
|
131
|
+
cur = parent.get(cur);
|
|
132
|
+
}
|
|
133
|
+
const key = frames.slice(0, 6).join(' < ') || '(no app frames)';
|
|
134
|
+
chains.set(key, (chains.get(key) || 0) + us);
|
|
135
|
+
}
|
|
136
|
+
const spawnTotalUs = [...chains.values()].reduce((a, b) => a + b, 0);
|
|
137
|
+
const spawnChains = [...chains.entries()]
|
|
138
|
+
.sort((a, b) => b[1] - a[1])
|
|
139
|
+
.slice(0, top)
|
|
140
|
+
.map(([chain, us]) => ({ chain, ms: Math.round(us / 1000) }));
|
|
141
|
+
return { total_ms: Math.round(totalUs / 1000), spawn_ms: Math.round(spawnTotalUs / 1000), hotspots, spawn_chains: spawnChains };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function median(values) {
|
|
145
|
+
if (!values.length) return 0;
|
|
146
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
147
|
+
const mid = Math.floor(sorted.length / 2);
|
|
148
|
+
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function timeRun(command, cwd, extraArgs = []) {
|
|
152
|
+
const start = process.hrtime.bigint();
|
|
153
|
+
const result = spawnSync(process.execPath, [...extraArgs, ...command], { cwd, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' });
|
|
154
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
155
|
+
return { ms: Math.round(ms), status: result.status, stderr: result.stderr || '' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveCommand(command) {
|
|
159
|
+
if (!command.length) return command;
|
|
160
|
+
const [script, ...rest] = command;
|
|
161
|
+
const abs = path.isAbsolute(script) ? script : path.resolve(process.cwd(), script);
|
|
162
|
+
return [fs.existsSync(abs) ? abs : script, ...rest];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
167
|
+
if (opts.help || !opts.command.length) {
|
|
168
|
+
process.stderr.write('usage: node scripts/det/cpu-hotspots.js [--cwd dir] [--top n] [--wall n] [--json] -- <script.js> [args...]\n');
|
|
169
|
+
return opts.help ? 0 : 2;
|
|
170
|
+
}
|
|
171
|
+
const command = resolveCommand(opts.command);
|
|
172
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-cpu-prof-'));
|
|
173
|
+
const profiled = timeRun(command, opts.cwd, ['--cpu-prof', `--cpu-prof-dir=${dir}`]);
|
|
174
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.cpuprofile'))
|
|
175
|
+
.map((f) => path.join(dir, f))
|
|
176
|
+
.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size);
|
|
177
|
+
if (!files.length) {
|
|
178
|
+
process.stderr.write(`no profile written (exit ${profiled.status})\n${profiled.stderr.slice(0, 800)}`);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const report = analyzeProfile(JSON.parse(fs.readFileSync(files[0], 'utf8')), { appRoot: opts.app, top: opts.top });
|
|
182
|
+
report.command = command.join(' ');
|
|
183
|
+
report.exit_code = profiled.status;
|
|
184
|
+
report.profiled_wall_ms = profiled.ms;
|
|
185
|
+
if (opts.wall) {
|
|
186
|
+
const walls = [];
|
|
187
|
+
for (let i = 0; i < opts.wall; i += 1) walls.push(timeRun(command, opts.cwd).ms);
|
|
188
|
+
report.wall_runs_ms = walls;
|
|
189
|
+
report.wall_median_ms = median(walls);
|
|
190
|
+
}
|
|
191
|
+
if (opts.keep) report.profile_dir = dir;
|
|
192
|
+
else fs.rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
const out = [];
|
|
199
|
+
out.push(`command: ${report.command} (exit ${report.exit_code})`);
|
|
200
|
+
out.push(`profiled: ${report.total_ms} ms cpu profile, ${report.profiled_wall_ms} ms wall, ${report.spawn_ms} ms in spawnSync`);
|
|
201
|
+
if (opts.wall) out.push(`wall median of ${opts.wall}: ${report.wall_median_ms} ms (${report.wall_runs_ms.join(', ')})`);
|
|
202
|
+
out.push('');
|
|
203
|
+
out.push('top self time:');
|
|
204
|
+
for (const row of report.hotspots) out.push(` ${String(row.ms).padStart(6)} ms ${row.frame}`);
|
|
205
|
+
out.push('');
|
|
206
|
+
out.push('top spawnSync chains:');
|
|
207
|
+
if (!report.spawn_chains.length) out.push(' (none)');
|
|
208
|
+
for (const row of report.spawn_chains) out.push(` ${String(row.ms).padStart(6)} ms ${row.chain}`);
|
|
209
|
+
if (report.profile_dir) out.push('', `profile dir: ${report.profile_dir}`);
|
|
210
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (require.main === module) process.exitCode = main();
|
|
215
|
+
|
|
216
|
+
module.exports = { analyzeProfile, median, parseArgs };
|