@worca/app 0.0.1
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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// src/core/skills.mjs
|
|
2
|
+
// Declared-skill resolution, validation, and worktree injection.
|
|
3
|
+
//
|
|
4
|
+
// Worca CC agents may declare `requiresSkills: string[]` in their meta sidecar.
|
|
5
|
+
// A skill is only reachable by the headless `claude -p` child when it sits on a
|
|
6
|
+
// scan path: `<cwd>/.claude/skills/<name>/` or `~/.claude/skills/<name>/`.
|
|
7
|
+
// Worca CC's own repo `skills/` dir is on NEITHER, so a bundled skill must be
|
|
8
|
+
// COPIED into the run's worktree before any node runs. This module:
|
|
9
|
+
// - resolveSkill() pure: where does a skill live? (bundle|global|project|none)
|
|
10
|
+
// - collectRequiredSkills() pure: union of requiresSkills across the plan's agents
|
|
11
|
+
// - validateSkills() gate: throw a structured abort if any are unresolvable
|
|
12
|
+
// - injectSkills() side-effect: copy bundle/plugin skills into given target(s)
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { cp } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // plugin skill roots (Task 2)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ordered plugin skill roots for resolveSkill's ctx.pluginDirs: every ENABLED
|
|
22
|
+
* plugin's current/skills dir that exists, lexicographic by plugin name (the
|
|
23
|
+
* same determinism as pluginAgentLayers). try/catch mirrors userAgentsDir():
|
|
24
|
+
* no resolvable home / no lock => [] — zero plugins is byte-identical to today.
|
|
25
|
+
* @returns {Array<{plugin: string, dir: string}>}
|
|
26
|
+
*/
|
|
27
|
+
export function pluginSkillDirs() {
|
|
28
|
+
try {
|
|
29
|
+
const lock = readPluginsLock();
|
|
30
|
+
return Object.keys(lock)
|
|
31
|
+
.sort()
|
|
32
|
+
.filter((name) => lock[name] && lock[name].enabled !== false)
|
|
33
|
+
.map((name) => ({ plugin: name, dir: join(pluginCurrentDir(name), 'skills') }))
|
|
34
|
+
.filter(({ dir }) => existsSync(dir));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* S2 (path-segment guard). A skill name is used as a PATH SEGMENT at both mount
|
|
42
|
+
* sites — `injectSkills`' `join(t, '.claude', 'skills', skill)` and
|
|
43
|
+
* `assembleSkills`' `join(target, effective)` (run-context.mjs) — and it arrives
|
|
44
|
+
* from author-controlled agent frontmatter (`requiresSkills`) or, on resume, from a
|
|
45
|
+
* disk-read `run.json.skillResolutions`. Unvalidated, `../../x` turns either mount
|
|
46
|
+
* into a write-anywhere primitive.
|
|
47
|
+
*
|
|
48
|
+
* The standard is `createWorktree`'s, verbatim (worktree.mjs S2/checkoutName): the
|
|
49
|
+
* conservative `[A-Za-z0-9._-]+` class plus the two dot cases the class alone still
|
|
50
|
+
* admits. Callers differ only in POSTURE — a declared skill is rejected LOUDLY at
|
|
51
|
+
* preflight (validateSkills), a manifest entry is SKIPPED with a warning (§8.20:
|
|
52
|
+
* a corrupt run.json must not make a paused run unresumable).
|
|
53
|
+
* @param {unknown} name
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function isValidSkillName(name) {
|
|
57
|
+
return typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && name !== '.' && name !== '..';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a skill name to its source, in priority order:
|
|
62
|
+
* 0. plugin (OWNER) — <plugins>/<name>/current/skills/<skill>/SKILL.md, only for
|
|
63
|
+
* the plugin named by ctx.origin ('plugin:<name>' — the
|
|
64
|
+
* requesting agent's registry origin)
|
|
65
|
+
* 1. bundle — <repoRoot>/skills/<name>/SKILL.md (injectSkills copies this into the worktree)
|
|
66
|
+
* 2. global — <homeDir>/.claude/skills/<name>/SKILL.md (already on the scan path)
|
|
67
|
+
* 3. project — <projectDir>/.claude/skills/<name>/SKILL.md (committed; already on the scan path)
|
|
68
|
+
* 4. plugin (others) — every remaining ctx.pluginDirs entry, in given order
|
|
69
|
+
* 5. none — unresolvable
|
|
70
|
+
* pluginDirs entries are { plugin, dir } (pluginSkillDirs()); a plain-string dir is
|
|
71
|
+
* tolerated (never owner, source 'plugin'). pluginDirs=[] + origin=null keeps the
|
|
72
|
+
* legacy 3-path chain and 3-entry `searched` byte-identical. Pure: existsSync only.
|
|
73
|
+
* @param {string} name
|
|
74
|
+
* @param {{repoRoot:string, projectDir:string, homeDir?:string,
|
|
75
|
+
* pluginDirs?:Array<{plugin:string,dir:string}|string>, origin?:string|null}} ctx
|
|
76
|
+
* @returns {{source:string|null, path:string|null, searched:string[]}}
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSkill(name, { repoRoot, projectDir, homeDir = homedir(), pluginDirs = [], origin = null }) {
|
|
79
|
+
const dirs = (Array.isArray(pluginDirs) ? pluginDirs : [])
|
|
80
|
+
.map((p) => (typeof p === 'string' ? { plugin: null, dir: p } : p))
|
|
81
|
+
.filter((p) => p && typeof p.dir === 'string' && p.dir);
|
|
82
|
+
const ownerName = typeof origin === 'string' && origin.startsWith('plugin:')
|
|
83
|
+
? origin.slice('plugin:'.length)
|
|
84
|
+
: null;
|
|
85
|
+
const asHit = (p) => ({ source: p.plugin ? `plugin:${p.plugin}` : 'plugin', dir: join(p.dir, name) });
|
|
86
|
+
const chain = [
|
|
87
|
+
...dirs.filter((p) => p.plugin !== null && p.plugin === ownerName).map(asHit), // owner FIRST
|
|
88
|
+
{ source: 'bundle', dir: join(repoRoot, 'skills', name) },
|
|
89
|
+
{ source: 'global', dir: join(homeDir, '.claude', 'skills', name) },
|
|
90
|
+
{ source: 'project', dir: join(projectDir, '.claude', 'skills', name) },
|
|
91
|
+
...dirs.filter((p) => p.plugin === null || p.plugin !== ownerName).map(asHit), // others LAST
|
|
92
|
+
];
|
|
93
|
+
const searched = chain.map((c) => join(c.dir, 'SKILL.md'));
|
|
94
|
+
for (let i = 0; i < chain.length; i++) {
|
|
95
|
+
if (existsSync(searched[i])) return { source: chain[i].source, path: chain[i].dir, searched };
|
|
96
|
+
}
|
|
97
|
+
return { source: null, path: null, searched };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Union of `requiresSkills` across the agents that appear in the resolved plan.
|
|
102
|
+
* Returns one entry per distinct skill with the agent keys that require it (for
|
|
103
|
+
* error attribution), sorted by skill name.
|
|
104
|
+
*
|
|
105
|
+
* `registry` is the plain object returned by loadAgentRegistry (keyed by agent
|
|
106
|
+
* key); `plan.steps` is Array<Array<node>> and each node has `.key`.
|
|
107
|
+
* @param {Record<string, {requiresSkills?: string[]}>} registry
|
|
108
|
+
* @param {{steps?: Array<Array<{key:string}>>}} plan
|
|
109
|
+
* @returns {Array<{skill:string, requiredBy:string[]}>}
|
|
110
|
+
*/
|
|
111
|
+
export function collectRequiredSkills(registry, plan) {
|
|
112
|
+
const nodeKeys = new Set();
|
|
113
|
+
for (const group of plan?.steps || []) for (const node of group) nodeKeys.add(node.key);
|
|
114
|
+
/** @type {Map<string, Set<string>>} */
|
|
115
|
+
const bySkill = new Map();
|
|
116
|
+
for (const key of nodeKeys) {
|
|
117
|
+
for (const skill of registry?.[key]?.requiresSkills || []) {
|
|
118
|
+
if (!bySkill.has(skill)) bySkill.set(skill, new Set());
|
|
119
|
+
bySkill.get(skill).add(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...bySkill.keys()].sort().map((skill) => {
|
|
123
|
+
const requiredBy = [...bySkill.get(skill)].sort();
|
|
124
|
+
// Owner attribution for plugin-first search: the FIRST (sorted) requiring
|
|
125
|
+
// agent whose registry meta carries a plugin origin. The key is OMITTED when
|
|
126
|
+
// none does, so legacy fixtures/deepEqual call sites are byte-identical.
|
|
127
|
+
const origin = requiredBy
|
|
128
|
+
.map((k) => registry?.[k]?.origin)
|
|
129
|
+
.find((o) => typeof o === 'string' && o.startsWith('plugin:'));
|
|
130
|
+
return origin ? { skill, requiredBy, origin } : { skill, requiredBy };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Preflight gate. Resolve every required skill; if ANY is unresolvable, throw a
|
|
136
|
+
* single aggregated error naming each (requiring agent(s), skill, searched paths)
|
|
137
|
+
* so the user fixes them all at once. On success, return the resolutions keyed by
|
|
138
|
+
* skill (consumed by injectSkills). Throws BEFORE any node runs; the thrown plain
|
|
139
|
+
* Error lands in run()'s error branch → run ends with status 'error', message
|
|
140
|
+
* surfaced.
|
|
141
|
+
* @param {Array<{skill:string, requiredBy:string[]}>} required
|
|
142
|
+
* @param {{repoRoot:string, projectDir:string, homeDir?:string}} ctx
|
|
143
|
+
* @returns {Map<string, {source:string, path:string, requiredBy:string[]}>}
|
|
144
|
+
*/
|
|
145
|
+
export function validateSkills(required, ctx) {
|
|
146
|
+
const resolved = new Map();
|
|
147
|
+
const missing = [];
|
|
148
|
+
const invalid = [];
|
|
149
|
+
for (const { skill, requiredBy, origin } of required) {
|
|
150
|
+
// S2 FIRST: a name that cannot be a path segment is never searched, so a
|
|
151
|
+
// traversal is always reported as a traversal instead of being masked by a
|
|
152
|
+
// "not found" listing of three joined-and-normalized paths.
|
|
153
|
+
if (!isValidSkillName(skill)) { invalid.push({ skill, requiredBy }); continue; }
|
|
154
|
+
const r = resolveSkill(skill, { ...ctx, origin: origin ?? null });
|
|
155
|
+
if (r.source === null) missing.push({ skill, requiredBy, searched: r.searched });
|
|
156
|
+
else resolved.set(skill, { source: r.source, path: r.path, requiredBy });
|
|
157
|
+
}
|
|
158
|
+
if (invalid.length) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Preflight failed: ${invalid.length} required skill(s) have an invalid skill name. ` +
|
|
161
|
+
'A skill name becomes a directory name under `.claude/skills/`, so it may contain only ' +
|
|
162
|
+
'letters, digits, `.`, `_` and `-`, and may not be `.` or `..`:\n' +
|
|
163
|
+
invalid
|
|
164
|
+
.map((m) => ` - ${JSON.stringify(m.skill)} (required by ${m.requiredBy.join(', ')})`)
|
|
165
|
+
.join('\n'),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
const lines = missing.map(
|
|
170
|
+
(m) =>
|
|
171
|
+
` - skill "${m.skill}" (required by ${m.requiredBy.join(', ')}) not found. Searched:\n` +
|
|
172
|
+
m.searched.map((p) => ` ${p}`).join('\n'),
|
|
173
|
+
);
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Preflight failed: ${missing.length} required skill(s) unavailable. ` +
|
|
176
|
+
`Bundle them in the worca-cc repo (skills/<name>/) or install under ~/.claude/skills/:\n` +
|
|
177
|
+
lines.join('\n'),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return resolved;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Copy each BUNDLE- or PLUGIN-sourced skill into every given target's
|
|
185
|
+
* .claude/skills/ (global/project skills are already on the scan path — nothing to
|
|
186
|
+
* copy). Runs ONLY after validateSkills passes. A copy failure rejects, aborting
|
|
187
|
+
* the run before any node starts (no half-injected skill dir reaches a node).
|
|
188
|
+
*
|
|
189
|
+
* `targets` MUST be dirs worca-cc owns — real isolated checkouts (never the main
|
|
190
|
+
* projectDir) or a run root. The orchestrator guards this before calling (see §5)
|
|
191
|
+
* so injection cannot pollute the user's working tree. The parameter is generic
|
|
192
|
+
* because a detached WORKSPACE run mounts once at the run root instead of N
|
|
193
|
+
* worktrees (§5.6 entry class 3); that call site lives in run-context.mjs, so under
|
|
194
|
+
* `detached` this function is not the delivery path at all — it stays the byte-
|
|
195
|
+
* identical LEGACY one (§10 rollback contract).
|
|
196
|
+
* @param {Map<string, {source:string, path:string}>} resolutions
|
|
197
|
+
* @param {{targets:string[]}} ctx
|
|
198
|
+
* @returns {Promise<string[]>} skill names actually injected
|
|
199
|
+
*/
|
|
200
|
+
export async function injectSkills(resolutions, { targets }) {
|
|
201
|
+
const injected = [];
|
|
202
|
+
for (const [skill, r] of resolutions) {
|
|
203
|
+
if (r.source !== 'bundle' && !String(r.source || '').startsWith('plugin:')) continue;
|
|
204
|
+
for (const t of targets || []) {
|
|
205
|
+
await cp(r.path, join(t, '.claude', 'skills', skill), { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
injected.push(skill);
|
|
208
|
+
}
|
|
209
|
+
return injected;
|
|
210
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// src/core/sources.mjs
|
|
2
|
+
// The task-source seam (spec §7.3): every way a pipeline acquires its task —
|
|
3
|
+
// inline prompt, markdown file/text, or a plugin task-source connector — resolves
|
|
4
|
+
// through ONE path yielding { promptText, promptFile, sourceMeta }.
|
|
5
|
+
// Feature-off bar: with zero plugins installed, 'prompt'/'markdown' resolution is
|
|
6
|
+
// byte-identical to the old inline prompt||promptFile branching in createPipeline.
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { callSource } from './plugin-shim.mjs';
|
|
12
|
+
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
|
|
13
|
+
import { normalizeManifest } from './plugin-manifest.mjs';
|
|
14
|
+
import { getDb } from './db.mjs';
|
|
15
|
+
import { runDirForRow, readStoreMeta } from './artifacts.mjs';
|
|
16
|
+
import { RESULTS_FILE } from './results.mjs';
|
|
17
|
+
import { hasGh, findPrForBranch } from './git-info.mjs';
|
|
18
|
+
|
|
19
|
+
/** Same resolve-against-projectDir semantics as artifacts.mjs#resolveAgainst. */
|
|
20
|
+
function resolveAgainst(base, p) {
|
|
21
|
+
return isAbsolute(p) ? p : resolve(base, p);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every selectable task source: the two built-ins plus one entry per task source
|
|
26
|
+
* of every ENABLED installed plugin (lexicographic plugin order, manifest order
|
|
27
|
+
* within a plugin). Broken/missing manifests are skipped — the pane must render.
|
|
28
|
+
* @returns {Array<{type:string, displayName:string, plugin?:string, sourceId?:string, inputs?:Array}>}
|
|
29
|
+
*/
|
|
30
|
+
export function listTaskSources() {
|
|
31
|
+
const sources = [
|
|
32
|
+
{ type: 'prompt', displayName: 'Prompt' },
|
|
33
|
+
{ type: 'markdown', displayName: 'Markdown' },
|
|
34
|
+
];
|
|
35
|
+
let lock = {};
|
|
36
|
+
try { lock = readPluginsLock(); } catch { lock = {}; }
|
|
37
|
+
for (const name of Object.keys(lock).sort()) {
|
|
38
|
+
if (lock[name]?.enabled === false) continue;
|
|
39
|
+
let manifest = null;
|
|
40
|
+
try {
|
|
41
|
+
const dir = pluginCurrentDir(name);
|
|
42
|
+
const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
|
|
43
|
+
manifest = norm.ok ? norm.manifest : null;
|
|
44
|
+
} catch { manifest = null; }
|
|
45
|
+
if (!manifest) continue;
|
|
46
|
+
for (const ts of manifest.taskSources || []) {
|
|
47
|
+
sources.push({
|
|
48
|
+
type: 'plugin',
|
|
49
|
+
plugin: name,
|
|
50
|
+
sourceId: ts.id,
|
|
51
|
+
displayName: ts.displayName || `${name}/${ts.id}`,
|
|
52
|
+
inputs: ts.inputs || [],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return sources;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `# title\n\nbody` + a fenced json meta block when the provider bag is non-empty. */
|
|
60
|
+
function taskPromptText(task) {
|
|
61
|
+
let text = `# ${task.title || task.id}\n\n${task.body || ''}`;
|
|
62
|
+
if (task.meta && typeof task.meta === 'object' && Object.keys(task.meta).length > 0) {
|
|
63
|
+
text += `\n\n\`\`\`json meta\n${JSON.stringify(task.meta, null, 2)}\n\`\`\``;
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a source descriptor to the pipeline's task input.
|
|
70
|
+
* { type:'prompt', prompt } | { type:'markdown', promptText?, promptFile? }
|
|
71
|
+
* | { type:'plugin', plugin, sourceId, taskId, inputs? }
|
|
72
|
+
* @returns {Promise<{promptText:string, promptFile:string|null,
|
|
73
|
+
* sourceMeta:{plugin,sourceId,taskId,url,title}|null}>}
|
|
74
|
+
*/
|
|
75
|
+
export async function resolveTaskInput(source, { projectDir } = {}) {
|
|
76
|
+
const src = source && typeof source === 'object' ? source : { type: 'prompt', prompt: '' };
|
|
77
|
+
const type = src.type || 'prompt';
|
|
78
|
+
|
|
79
|
+
if (type === 'prompt') {
|
|
80
|
+
return { promptText: typeof src.prompt === 'string' ? src.prompt : '', promptFile: null, sourceMeta: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (type === 'markdown') {
|
|
84
|
+
if (src.promptFile) {
|
|
85
|
+
let promptText = '';
|
|
86
|
+
try {
|
|
87
|
+
promptText = await readFile(resolveAgainst(projectDir, src.promptFile), 'utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
promptText = ''; // exactly the legacy createPipeline catch{} degradation
|
|
90
|
+
}
|
|
91
|
+
return { promptText, promptFile: src.promptFile, sourceMeta: null };
|
|
92
|
+
}
|
|
93
|
+
return { promptText: typeof src.promptText === 'string' ? src.promptText : '', promptFile: null, sourceMeta: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === 'plugin') {
|
|
97
|
+
const task = await callSource({ plugin: src.plugin, sourceId: src.sourceId, op: 'getTask', args: { id: src.taskId } });
|
|
98
|
+
if (!task) {
|
|
99
|
+
throw new Error(`task-source ${src.plugin}/${src.sourceId}: task "${src.taskId}" not found`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
promptText: taskPromptText(task),
|
|
103
|
+
promptFile: null,
|
|
104
|
+
sourceMeta: {
|
|
105
|
+
plugin: src.plugin,
|
|
106
|
+
sourceId: src.sourceId,
|
|
107
|
+
taskId: src.taskId,
|
|
108
|
+
url: task.url ?? null,
|
|
109
|
+
title: task.title ?? null,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(`unknown task source type "${type}"`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── result write-back (spec §7.5) ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Map a pipeline row status onto the connector reportResult vocabulary (§7.1). */
|
|
120
|
+
function statusToResult(status) {
|
|
121
|
+
if (status === 'done') return 'completed';
|
|
122
|
+
if (status === 'error' || status === 'stopped') return 'failed';
|
|
123
|
+
return 'needs-human'; // paused | interrupted | anything non-terminal (manual retry path)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Markdown summary assembled from the persisted results view (results.mjs#assembleResults shape). */
|
|
127
|
+
function buildResultSummary(row, bundle) {
|
|
128
|
+
const lines = [`### Worca CC run \`${row.id}\` — ${row.status}`];
|
|
129
|
+
if (row.title) lines.push('', `**${row.title}**`);
|
|
130
|
+
const s = bundle?.results?.summary; // { filesNew, filesChanged, filesDeleted, linesAdded, linesRemoved, blockingIssues, nitpicks }
|
|
131
|
+
if (s) {
|
|
132
|
+
lines.push('', `- Diffstat: ${s.filesChanged ?? 0} changed, ${s.filesNew ?? 0} new, ${s.filesDeleted ?? 0} deleted, +${s.linesAdded ?? 0} / -${s.linesRemoved ?? 0}`);
|
|
133
|
+
lines.push(`- Review: ${s.blockingIssues ?? 0} blocking, ${s.nitpicks ?? 0} nitpicks`);
|
|
134
|
+
}
|
|
135
|
+
if (bundle?.branch) lines.push(`- Branch: \`${bundle.branch}\``); // local branches have no URL — named here, linked below only as a PR
|
|
136
|
+
const checks = Array.isArray(bundle?.results?.keyThingsToCheck) ? bundle.results.keyThingsToCheck : [];
|
|
137
|
+
if (checks.length) {
|
|
138
|
+
lines.push('', 'Key things to check:');
|
|
139
|
+
for (const c of checks.slice(0, 5)) lines.push(`- [${c.severity}] ${c.title}${c.file ? ` (\`${c.file}\`)` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Tracker-comment links: the PR when the bundle knows one. */
|
|
145
|
+
function buildResultLinks(bundle) {
|
|
146
|
+
const links = [];
|
|
147
|
+
if (bundle?.prUrl) links.push({ title: 'Pull request', url: bundle.prUrl });
|
|
148
|
+
return links;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Report a finished pipeline back to its plugin task source. NEVER throws and
|
|
153
|
+
* NEVER blocks completion semantics: every failure collapses to { ok:false,
|
|
154
|
+
* error } for the caller to log/surface. Silent skip ({ ok:true, skipped:true })
|
|
155
|
+
* for prompt/markdown rows and refs that cannot be parsed.
|
|
156
|
+
* @param {object} pipelineRow raw pipelines row (source_type/source_ref/status/title)
|
|
157
|
+
* @param {{results:object|null, branch:string|null, prUrl:string|null}} resultsBundle
|
|
158
|
+
* @returns {Promise<{ok:true, skipped?:true} | {ok:false, error:string}>}
|
|
159
|
+
*/
|
|
160
|
+
export async function reportResultForPipeline(pipelineRow, resultsBundle) {
|
|
161
|
+
try {
|
|
162
|
+
if ((pipelineRow?.source_type || 'prompt') !== 'plugin') return { ok: true, skipped: true };
|
|
163
|
+
let ref = null;
|
|
164
|
+
try { ref = pipelineRow.source_ref ? JSON.parse(pipelineRow.source_ref) : null; } catch { ref = null; }
|
|
165
|
+
if (!ref?.plugin || !ref?.sourceId || !ref?.taskId) return { ok: true, skipped: true };
|
|
166
|
+
|
|
167
|
+
// Capability probe with a TOLERANT DEFAULT. capabilities() is optional in the
|
|
168
|
+
// connector contract (§7.1: "defaults: writeBack true"): a connector without
|
|
169
|
+
// the op makes the child answer { ok:false, error:{ kind:'plugin', message:
|
|
170
|
+
// 'connector does not implement op "capabilities"' } } -> callSource throws
|
|
171
|
+
// PluginOpError('plugin') -> we default to writeBack:true. Transport errors
|
|
172
|
+
// (auth/network/timeout/protocol) ALSO default to true — the reportResult
|
|
173
|
+
// call below is the one that surfaces the real failure to the caller.
|
|
174
|
+
let writeBack = true;
|
|
175
|
+
try {
|
|
176
|
+
const caps = await callSource({ plugin: ref.plugin, sourceId: ref.sourceId, op: 'capabilities', args: {} });
|
|
177
|
+
if (caps && caps.writeBack === false) writeBack = false;
|
|
178
|
+
} catch {
|
|
179
|
+
writeBack = true;
|
|
180
|
+
}
|
|
181
|
+
if (!writeBack) return { ok: true, skipped: true };
|
|
182
|
+
|
|
183
|
+
await callSource({
|
|
184
|
+
plugin: ref.plugin,
|
|
185
|
+
sourceId: ref.sourceId,
|
|
186
|
+
op: 'reportResult',
|
|
187
|
+
args: {
|
|
188
|
+
id: ref.taskId,
|
|
189
|
+
status: statusToResult(pipelineRow.status),
|
|
190
|
+
summary: buildResultSummary(pipelineRow, resultsBundle),
|
|
191
|
+
links: buildResultLinks(resultsBundle),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
return { ok: true };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, error: err?.message || String(err) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load everything write-back needs for one pipeline and report it. Used by the
|
|
202
|
+
* orchestrator's terminal hook AND (Task 15) the results-view "Report result"
|
|
203
|
+
* retry endpoint. Skips BEFORE any bundle work for prompt/markdown rows, so
|
|
204
|
+
* feature-off runs never touch git/gh/results here. NEVER throws.
|
|
205
|
+
* @param {string} pipelineId
|
|
206
|
+
*/
|
|
207
|
+
export async function retryWriteback(pipelineId) {
|
|
208
|
+
try {
|
|
209
|
+
const row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(pipelineId);
|
|
210
|
+
if (!row) return { ok: false, error: `unknown pipeline "${pipelineId}"` };
|
|
211
|
+
if ((row.source_type || 'prompt') !== 'plugin') return { ok: true, skipped: true };
|
|
212
|
+
|
|
213
|
+
const dir = await runDirForRow(row);
|
|
214
|
+
let results = null;
|
|
215
|
+
try { results = JSON.parse(await readFile(join(dir, RESULTS_FILE), 'utf8')); } catch { results = null; }
|
|
216
|
+
let branch = null;
|
|
217
|
+
try { branch = row.branch ? (JSON.parse(row.branch)?.feature ?? null) : null; } catch { branch = null; }
|
|
218
|
+
// PR link, best-effort (same hasGh-gated pattern as artifacts.mjs#rowToHistoryEntry).
|
|
219
|
+
let prUrl = null;
|
|
220
|
+
if (branch) {
|
|
221
|
+
try {
|
|
222
|
+
const meta = readStoreMeta(row.project_key);
|
|
223
|
+
if (meta?.path && (await hasGh())) {
|
|
224
|
+
prUrl = (await findPrForBranch({ projectDir: meta.path, head: branch }))?.url || null;
|
|
225
|
+
}
|
|
226
|
+
} catch { prUrl = null; }
|
|
227
|
+
}
|
|
228
|
+
return await reportResultForPipeline(row, { results, branch, prUrl });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { ok: false, error: err?.message || String(err) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/core/stats.mjs
|
|
2
|
+
// Statistics over ALL pipeline records — archived included, deliberately: the
|
|
3
|
+
// archive exists so these numbers survive history cleanup (spec §6.9). Pure
|
|
4
|
+
// synchronous DB reads; no gh, no git, offline-correct.
|
|
5
|
+
//
|
|
6
|
+
// Attribution: money = cost_ledger by event timestamp (exact); runs, time,
|
|
7
|
+
// and PRs = cohort by started_at (a run belongs to the bucket its start falls
|
|
8
|
+
// in, with its CURRENT status). 'today' differs: its totals count runs whose
|
|
9
|
+
// lifespan [started_at, updated_at] overlaps the day ("active today"), and its
|
|
10
|
+
// hourly bars attribute terminal outcomes to the hour of the terminal write
|
|
11
|
+
// (updated_at). 'all' money/time use the fallback-aware pipelines sums so
|
|
12
|
+
// pre-ledger history still counts.
|
|
13
|
+
|
|
14
|
+
import { prepare } from './db.mjs';
|
|
15
|
+
import {
|
|
16
|
+
budgetStatus, costWindowStart, costWindowEnd, allTimeTotals, roundUsd,
|
|
17
|
+
} from './cost-budget.mjs';
|
|
18
|
+
|
|
19
|
+
const RANGES = ['today', 'week', 'month', 'all'];
|
|
20
|
+
|
|
21
|
+
function hourStart(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); }
|
|
22
|
+
function dayStart(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
|
|
23
|
+
function monthStart(d) { return new Date(d.getFullYear(), d.getMonth(), 1); }
|
|
24
|
+
|
|
25
|
+
const TOTALS_SELECT = `
|
|
26
|
+
SELECT COUNT(*) AS runs,
|
|
27
|
+
COALESCE(SUM(status = 'done'), 0) AS finished,
|
|
28
|
+
COALESCE(SUM(status = 'stopped'), 0) AS stopped,
|
|
29
|
+
COALESCE(SUM(status = 'error'), 0) AS failed,
|
|
30
|
+
COALESCE(SUM(status IN ('paused','interrupted')), 0) AS paused,
|
|
31
|
+
COALESCE(SUM(status IN ('created','starting','running','pausing')), 0) AS running,
|
|
32
|
+
COALESCE(SUM(pr_url IS NOT NULL), 0) AS prsOpened,
|
|
33
|
+
COALESCE(SUM(pr_state = 'MERGED'), 0) AS prsMerged,
|
|
34
|
+
COALESCE(SUM(CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END), 0) AS spend,
|
|
35
|
+
COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active
|
|
36
|
+
FROM pipelines p
|
|
37
|
+
LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc, SUM(active_ms) sa
|
|
38
|
+
FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id`;
|
|
39
|
+
|
|
40
|
+
function totalsRow(sql, isoA, isoB) {
|
|
41
|
+
const row = prepare(sql).get(isoA, isoB);
|
|
42
|
+
return {
|
|
43
|
+
runs: row.runs, finished: row.finished, stopped: row.stopped, failed: row.failed,
|
|
44
|
+
paused: row.paused, running: row.running,
|
|
45
|
+
prsOpened: row.prsOpened, prsMerged: row.prsMerged,
|
|
46
|
+
workedMs: Number(row.active || 0),
|
|
47
|
+
cohortSpendUsd: roundUsd(row.spend || 0),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cohort totals over runs started in [fromMs, toMs). Legacy fs->db imports have
|
|
52
|
+
* no started_at; they fall back to updated_at so their money (which always
|
|
53
|
+
* lands in the all-time sums) is never counted without its run. */
|
|
54
|
+
function cohortTotals(fromMs, toMs) {
|
|
55
|
+
return totalsRow(`${TOTALS_SELECT}
|
|
56
|
+
WHERE COALESCE(p.started_at, p.updated_at) >= ?
|
|
57
|
+
AND COALESCE(p.started_at, p.updated_at) < ?`,
|
|
58
|
+
new Date(fromMs).toISOString(), new Date(toMs).toISOString());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Runs whose lifespan [started_at, updated_at] overlaps [fromMs, toMs) —
|
|
62
|
+
* "active in the window": started, updated, or finished inside it. There is
|
|
63
|
+
* no finished_at column; updated_at is the terminal-write proxy. */
|
|
64
|
+
function activeTotals(fromMs, toMs) {
|
|
65
|
+
return totalsRow(`${TOTALS_SELECT}
|
|
66
|
+
WHERE COALESCE(p.started_at, p.updated_at) < ?
|
|
67
|
+
AND COALESCE(p.updated_at, p.started_at) >= ?`,
|
|
68
|
+
new Date(toMs).toISOString(), new Date(fromMs).toISOString());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Runs whose LAST write (updated_at, ≈ the terminal write for finished runs)
|
|
72
|
+
* falls in [fromMs, toMs) — hourly outcome attribution for the today range. */
|
|
73
|
+
function updatedTotals(fromMs, toMs) {
|
|
74
|
+
return totalsRow(`${TOTALS_SELECT}
|
|
75
|
+
WHERE COALESCE(p.updated_at, p.started_at) >= ?
|
|
76
|
+
AND COALESCE(p.updated_at, p.started_at) < ?`,
|
|
77
|
+
new Date(fromMs).toISOString(), new Date(toMs).toISOString());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Ledger spend in [fromMs, toMs). */
|
|
81
|
+
function ledgerSpend(fromMs, toMs) {
|
|
82
|
+
const row = prepare(
|
|
83
|
+
'SELECT SUM(amount_usd) AS s FROM cost_ledger WHERE ts >= ? AND ts < ?').get(fromMs, toMs);
|
|
84
|
+
return roundUsd(row?.s || 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build zero-filled buckets [{startMs, endMs}] from windowStart through `now`. */
|
|
88
|
+
function buildBuckets(windowStart, now, bucket) {
|
|
89
|
+
const out = [];
|
|
90
|
+
if (bucket === 'hour') {
|
|
91
|
+
for (let h = hourStart(windowStart); h <= now;
|
|
92
|
+
h = new Date(h.getFullYear(), h.getMonth(), h.getDate(), h.getHours() + 1)) {
|
|
93
|
+
out.push({ startMs: h.getTime(),
|
|
94
|
+
endMs: new Date(h.getFullYear(), h.getMonth(), h.getDate(), h.getHours() + 1).getTime() });
|
|
95
|
+
}
|
|
96
|
+
} else if (bucket === 'day') {
|
|
97
|
+
for (let d = dayStart(windowStart); d <= now; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)) {
|
|
98
|
+
out.push({ startMs: d.getTime(), endMs: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() });
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
for (let m = monthStart(windowStart); m <= now; m = new Date(m.getFullYear(), m.getMonth() + 1, 1)) {
|
|
102
|
+
out.push({ startMs: m.getTime(), endMs: new Date(m.getFullYear(), m.getMonth() + 1, 1).getTime() });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** GET /api/stats backing (spec §6.9). @throws {RangeError} on unknown range. */
|
|
109
|
+
export function getStats({ range = 'month', now = new Date() } = {}) {
|
|
110
|
+
if (!RANGES.includes(range)) throw new RangeError(`unknown stats range: ${range}`);
|
|
111
|
+
const budget = budgetStatus(now);
|
|
112
|
+
|
|
113
|
+
let windowStart, windowEnd, prevStart, prevEnd, bucket;
|
|
114
|
+
if (range === 'today') {
|
|
115
|
+
windowStart = dayStart(now);
|
|
116
|
+
windowEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
117
|
+
prevStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
|
118
|
+
prevEnd = windowStart; bucket = 'hour';
|
|
119
|
+
} else if (range === 'week') {
|
|
120
|
+
windowStart = costWindowStart(now, 'weekly'); windowEnd = costWindowEnd(now, 'weekly');
|
|
121
|
+
prevStart = new Date(windowStart.getFullYear(), windowStart.getMonth(), windowStart.getDate() - 7);
|
|
122
|
+
prevEnd = windowStart; bucket = 'day';
|
|
123
|
+
} else if (range === 'month') {
|
|
124
|
+
windowStart = costWindowStart(now, 'monthly'); windowEnd = costWindowEnd(now, 'monthly');
|
|
125
|
+
prevStart = new Date(windowStart.getFullYear(), windowStart.getMonth() - 1, 1);
|
|
126
|
+
prevEnd = windowStart; bucket = 'day';
|
|
127
|
+
} else {
|
|
128
|
+
// all time: series over the last 12 months; totals over everything
|
|
129
|
+
windowStart = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
|
130
|
+
windowEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
131
|
+
prevStart = null; prevEnd = null; bucket = 'month';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 'today' counts runs ACTIVE in the window (Q&A: started, updated, or
|
|
135
|
+
// finished today); every other range keeps cohort-by-start. prev flows
|
|
136
|
+
// through the same closure, so the yesterday delta compares like with like.
|
|
137
|
+
const windowTotalsFn = range === 'today' ? activeTotals : cohortTotals;
|
|
138
|
+
const shapeTotals = (fromMs, toMs) => {
|
|
139
|
+
const c = windowTotalsFn(fromMs, toMs);
|
|
140
|
+
return {
|
|
141
|
+
spentUsd: ledgerSpend(fromMs, toMs),
|
|
142
|
+
workedMs: c.workedMs,
|
|
143
|
+
runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
|
|
144
|
+
paused: c.paused, running: c.running,
|
|
145
|
+
prsOpened: c.prsOpened, prsMerged: c.prsMerged,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let totals;
|
|
150
|
+
if (range === 'all') {
|
|
151
|
+
const at = allTimeTotals();
|
|
152
|
+
const c = cohortTotals(0, windowEnd.getTime());
|
|
153
|
+
totals = {
|
|
154
|
+
spentUsd: at.spendUsd, workedMs: at.activeMs,
|
|
155
|
+
runs: c.runs, finished: c.finished, stopped: c.stopped, failed: c.failed,
|
|
156
|
+
paused: c.paused, running: c.running,
|
|
157
|
+
prsOpened: c.prsOpened, prsMerged: c.prsMerged,
|
|
158
|
+
};
|
|
159
|
+
} else {
|
|
160
|
+
totals = shapeTotals(windowStart.getTime(), windowEnd.getTime());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const prev = prevStart ? shapeTotals(prevStart.getTime(), prevEnd.getTime()) : null;
|
|
164
|
+
|
|
165
|
+
// Hourly bars pin terminal outcomes to the hour of the terminal write; the
|
|
166
|
+
// day/month bars keep cohort-by-start. Money is ledger-exact either way.
|
|
167
|
+
const bucketTotalsFn = range === 'today' ? updatedTotals : cohortTotals;
|
|
168
|
+
const series = buildBuckets(windowStart, now, bucket).map(({ startMs, endMs }) => {
|
|
169
|
+
const c = bucketTotalsFn(startMs, endMs);
|
|
170
|
+
return {
|
|
171
|
+
bucketStartMs: startMs,
|
|
172
|
+
spentUsd: ledgerSpend(startMs, endMs),
|
|
173
|
+
finished: c.finished, stopped: c.stopped, failed: c.failed,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
range, bucket,
|
|
179
|
+
windowStartMs: windowStart.getTime(), windowEndMs: windowEnd.getTime(),
|
|
180
|
+
totals, prev, budget, series,
|
|
181
|
+
};
|
|
182
|
+
}
|