@oharness/server 0.9.0 → 0.9.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/dist/server.js CHANGED
@@ -18,7 +18,7 @@ import { visibleText } from "@oharness/engine/core/types.ts";
18
18
  import { errorMessage } from "@oharness/engine/core/errors.ts";
19
19
  import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
20
20
  import { loadUserConfig, updateUserConfig } from "@oharness/engine/config.ts";
21
- import { EXTENSION_ID, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
21
+ import { EXTENSION_IDS, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
22
22
  import { announcePresence } from "@oharness/engine/browser/presence.ts";
23
23
  import { extensionHostReachable } from "@oharness/engine/browser/socket.ts";
24
24
  import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
@@ -38,6 +38,7 @@ import { artifactDocument, sendArtifact } from "./artifacts.js";
38
38
  import { sendProjectExport } from "./project-export.js";
39
39
  import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
40
40
  import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
41
+ import { accountUsage } from "./account-usage.js";
41
42
  /**
42
43
  * How many stored sessions the usage page counts.
43
44
  *
@@ -47,6 +48,15 @@ import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engi
47
48
  * stops short says so rather than reading as the whole history.
48
49
  */
49
50
  const USAGE_SESSION_SCAN = 200;
51
+ /**
52
+ * How far back the sessions page reads, and how many rows it sends at a time.
53
+ *
54
+ * The page is "every session that spent something", so it reads further back
55
+ * than the usage summary does — but still a bounded number, and it says when
56
+ * it stopped. A page at a time because the list is drawn as it is scrolled.
57
+ */
58
+ const USAGE_SESSIONS_SCAN = 5000;
59
+ const USAGE_SESSIONS_PAGE = 50;
50
60
  /**
51
61
  * How many trail entries one read of every session may return.
52
62
  *
@@ -452,7 +462,7 @@ export async function startWebServer(opts) {
452
462
  "content-type": "text/html; charset=utf-8",
453
463
  // Framed by itself and by the OHarness extension's side panel, and by
454
464
  // nothing else: a page that drives a shell is not one to embed.
455
- "content-security-policy": `frame-ancestors 'self' chrome-extension://${EXTENSION_ID}`,
465
+ "content-security-policy": `frame-ancestors 'self' ${EXTENSION_IDS.map((id) => `chrome-extension://${id}`).join(" ")}`,
456
466
  });
457
467
  res.end(PAGE);
458
468
  return;
@@ -2124,6 +2134,22 @@ export async function startWebServer(opts) {
2124
2134
  })),
2125
2135
  });
2126
2136
  }
2137
+ /**
2138
+ * The account's spend, from the site's ledger rather than this machine.
2139
+ *
2140
+ * What `GET /usage` cannot see: chat from other devices, and every image,
2141
+ * video and clip of audio, which are billed by the gateway and settled
2142
+ * after the call that started them. Answers `signedIn: false` for a
2143
+ * machine with no account, so the page falls back to its own figures.
2144
+ */
2145
+ case "GET /usage/account": {
2146
+ return send(res, 200, await accountUsage({
2147
+ token: await current.harness.accountToken(),
2148
+ team: officialTeam(),
2149
+ site: siteUrl(),
2150
+ query: url.searchParams,
2151
+ }));
2152
+ }
2127
2153
  /**
2128
2154
  * What has been spent on this machine, and on what.
2129
2155
  *
@@ -2137,6 +2163,33 @@ export async function startWebServer(opts) {
2137
2163
  * mid-turn has already spent money its metadata will not know about
2138
2164
  * until the turn lands, and the page asking is the page that spent it.
2139
2165
  */
2166
+ /**
2167
+ * Every session that spent something, newest first, a page at a time.
2168
+ *
2169
+ * The usage page lists the latest few; this is the rest of them, with a
2170
+ * search over the title and the model. Filtered here rather than in the
2171
+ * page so a search reaches sessions the page has not loaded yet.
2172
+ */
2173
+ case "GET /usage/sessions": {
2174
+ const { stored, rows } = await usageRows(USAGE_SESSIONS_SCAN);
2175
+ const query = (url.searchParams.get("q") || "").trim().toLowerCase();
2176
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
2177
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || USAGE_SESSIONS_PAGE));
2178
+ const list = [...rows.values()]
2179
+ .filter((row) => row.costUsd > 0 || row.input || row.output || row.cacheRead || row.cacheWrite)
2180
+ .filter((row) => !query || (row.title || "").toLowerCase().includes(query) || row.model.toLowerCase().includes(query))
2181
+ .sort((a, b) => b.updatedAt - a.updatedAt);
2182
+ return send(res, 200, {
2183
+ sessions: list.slice(offset, offset + limit).map((row) => ({
2184
+ ...row,
2185
+ current: row.id === current.session.id,
2186
+ })),
2187
+ total: list.length,
2188
+ offset,
2189
+ more: offset + limit < list.length,
2190
+ truncated: stored.length >= USAGE_SESSIONS_SCAN,
2191
+ });
2192
+ }
2140
2193
  case "GET /usage": {
2141
2194
  // How far back the page is asking about. Everything below — the
2142
2195
  // totals, the models, the sessions and the curve — is that window and
@@ -2144,33 +2197,7 @@ export async function startWebServer(opts) {
2144
2197
  // from the chart under it is worse than no figure.
2145
2198
  const window = usageWindow(url.searchParams.get("range"), url.searchParams.get("from"), url.searchParams.get("to"));
2146
2199
  const { since, until } = window;
2147
- const stored = await current.harness.listSessions(USAGE_SESSION_SCAN);
2148
- const rows = new Map();
2149
- for (const meta of stored) {
2150
- rows.set(meta.id, {
2151
- id: meta.id,
2152
- title: meta.title,
2153
- model: meta.model,
2154
- updatedAt: meta.updatedAt,
2155
- turns: meta.turns,
2156
- costUsd: meta.costUsd || 0,
2157
- ...tokensOf(meta.usage),
2158
- });
2159
- }
2160
- for (const runner of runners.values()) {
2161
- const sess = runner.session;
2162
- rows.set(sess.id, {
2163
- id: sess.id,
2164
- title: sess.title,
2165
- model: sess.model.id,
2166
- // A session opened but not yet spoken to has no stored copy and no
2167
- // last-updated time of its own; it is here, so it is now.
2168
- updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
2169
- turns: sess.turns,
2170
- costUsd: sess.costUsd,
2171
- ...tokensOf(sess.usage),
2172
- });
2173
- }
2200
+ const { stored, rows } = await usageRows(USAGE_SESSION_SCAN);
2174
2201
  // Sessions that never sent anything are dropped: they contribute zero
2175
2202
  // to every total here, and a page about what was spent that opens with
2176
2203
  // a screen of "$0.0000 · 0 tokens" buries the ones that cost money.
@@ -2216,7 +2243,7 @@ export async function startWebServer(opts) {
2216
2243
  models: [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd),
2217
2244
  // A page of them is a page nobody reads. The rest are counted in the
2218
2245
  // totals above, which is where a number that large belongs.
2219
- sessions: list.slice(0, 20).map((row) => ({
2246
+ sessions: list.slice(0, 10).map((row) => ({
2220
2247
  ...row,
2221
2248
  current: row.id === current.session.id,
2222
2249
  })),
@@ -3571,6 +3598,44 @@ export async function startWebServer(opts) {
3571
3598
  * log carries. Everything else is folded from the revision logs, which is
3572
3599
  * what makes the panel's answer independent of which tabs are open.
3573
3600
  */
3601
+ /**
3602
+ * One row per session: the stored ones, with the open ones laid over their
3603
+ * stored copy. A session mid-turn has already spent money its metadata will
3604
+ * not know about until the turn lands, and the page asking is the page that
3605
+ * spent it.
3606
+ */
3607
+ async function usageRows(scan) {
3608
+ const stored = await current.harness.listSessions(scan);
3609
+ const rows = new Map();
3610
+ for (const meta of stored) {
3611
+ rows.set(meta.id, {
3612
+ id: meta.id,
3613
+ cwd: meta.cwd,
3614
+ title: meta.title,
3615
+ model: meta.model,
3616
+ updatedAt: meta.updatedAt,
3617
+ turns: meta.turns,
3618
+ costUsd: meta.costUsd || 0,
3619
+ ...tokensOf(meta.usage),
3620
+ });
3621
+ }
3622
+ for (const runner of runners.values()) {
3623
+ const sess = runner.session;
3624
+ rows.set(sess.id, {
3625
+ id: sess.id,
3626
+ cwd: rows.get(sess.id)?.cwd ?? runner.harness.cwd,
3627
+ title: sess.title,
3628
+ model: sess.model.id,
3629
+ // A session opened but not yet spoken to has no stored copy and no
3630
+ // last-updated time of its own; it is here, so it is now.
3631
+ updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
3632
+ turns: sess.turns,
3633
+ costUsd: sess.costUsd,
3634
+ ...tokensOf(sess.usage),
3635
+ });
3636
+ }
3637
+ return { stored, rows };
3638
+ }
3574
3639
  async function artifactCatalogue() {
3575
3640
  const groups = [];
3576
3641
  for (const runner of runners.values()) {