hammerkit 2.0.0-next.4 → 2.0.0-next.6

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.
Files changed (46) hide show
  1. package/dist/cache/explain.js +7 -5
  2. package/dist/dashboard/assets/graph-canvas-DLioOiRN.css +1 -0
  3. package/dist/dashboard/assets/graph-canvas-HmkW_-XJ.js +8 -0
  4. package/dist/dashboard/assets/index-gIBDC_m8.css +2 -0
  5. package/dist/dashboard/assets/index-vE22S5E_.js +17 -0
  6. package/dist/dashboard/index.html +3 -3
  7. package/dist/dashboard/logo.png +0 -0
  8. package/dist/executer/docker-task.js +3 -0
  9. package/dist/executer/execute-work-task.js +3 -1
  10. package/dist/executer/local-task.js +3 -0
  11. package/dist/executer/scheduler/enqueue-next.js +6 -1
  12. package/dist/executer/scheduler/state-key.js +71 -34
  13. package/dist/executer/watch-loop.js +18 -5
  14. package/dist/file/get-file-context.js +5 -1
  15. package/dist/kubernetes/claim-layout.js +10 -1
  16. package/dist/kubernetes/ensure-persistent-data.js +45 -5
  17. package/dist/kubernetes/follow-pod-logs.js +124 -27
  18. package/dist/kubernetes/network-policy.js +4 -4
  19. package/dist/kubernetes/restore-kubernetes-data.js +3 -1
  20. package/dist/kubernetes/store-kubernetes-data.js +3 -1
  21. package/dist/kubernetes/volumes.js +6 -6
  22. package/dist/optimizer/calculate-checksum.js +46 -3
  23. package/dist/optimizer/get-work-cache-stats.js +36 -24
  24. package/dist/planner/work-item-executions.js +27 -0
  25. package/dist/planner/work-runtime-kubernetes.js +33 -38
  26. package/dist/remote/remote-work-commands.js +11 -2
  27. package/dist/server/capacity.js +63 -0
  28. package/dist/server/dashboard.js +27 -9
  29. package/dist/server/endpoints.js +10 -1
  30. package/dist/server/executor.js +10 -0
  31. package/dist/server/oauth.js +16 -1
  32. package/dist/server/overview.js +80 -11
  33. package/dist/server/plan.js +16 -3
  34. package/dist/server/policy.js +9 -3
  35. package/dist/server/run-worker.js +37 -4
  36. package/dist/server/runs.js +74 -6
  37. package/dist/server/server-config.js +4 -1
  38. package/dist/server/server.js +45 -10
  39. package/dist/server/service-runs.js +8 -1
  40. package/dist/server/timings.js +23 -3
  41. package/dist/utils/limit.js +30 -0
  42. package/dist/utils/web-links.js +34 -0
  43. package/package.json +1 -1
  44. package/dist/dashboard/assets/index-CjnKm5-3.css +0 -2
  45. package/dist/dashboard/assets/index-zPXa4klZ.js +0 -13
  46. package/dist/dashboard/favicon.svg +0 -1
@@ -519,16 +519,25 @@ function registerWorkCommands(remote, context) {
519
519
  .command('tasks [server]')
520
520
  .description('show what the past runs of each task took, over the runs the server keeps: how often it ran, failed and came from the cache, the median and 90th percentile of its time, and how long its runs waited for a place')
521
521
  .addOption(new commander_1.Option('--mine', 'only your runs'))
522
+ .addOption(new commander_1.Option('--since <when>', 'only the runs created since then: back from now, such as 24h or 7d, or a time'))
523
+ .addOption(new commander_1.Option('--until <when>', 'only the runs created until then: back from now, or a time'))
522
524
  .addOption(new commander_1.Option('--json', 'print it as the server answers it'))
523
525
  .action(guarded('Remote tasks was not successful', async (name, options) => {
524
526
  const { name: serverName, server, token } = await connect(name);
525
- const { tasks } = (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/tasks?scope=${options.mine ? 'mine' : 'visible'}`)).body;
527
+ const query = new URLSearchParams({ scope: options.mine ? 'mine' : 'visible' });
528
+ if (options.since) {
529
+ query.set('since', options.since);
530
+ }
531
+ if (options.until) {
532
+ query.set('until', options.until);
533
+ }
534
+ const { tasks } = (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/tasks?${query}`)).body;
526
535
  if (options.json) {
527
536
  environment.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
528
537
  return;
529
538
  }
530
539
  if (tasks.length === 0) {
531
- environment.stdout.write(`${serverName}: no task ran in the runs it keeps\n`);
540
+ environment.stdout.write(`${serverName}: no task ran in the runs it keeps${options.since || options.until ? ' of that period' : ''}\n`);
532
541
  return;
533
542
  }
534
543
  for (const line of (0, remote_status_1.describeTaskStats)(tasks)) {
@@ -4,6 +4,9 @@ exports.formatMemory = exports.formatCpu = exports.HostCapacity = exports.Kubern
4
4
  const os_1 = require("os");
5
5
  const log_1 = require("../log");
6
6
  const apply_1 = require("../kubernetes/apply");
7
+ const job_placement_1 = require("../kubernetes/job-placement");
8
+ const volumes_1 = require("../kubernetes/volumes");
9
+ const network_policy_1 = require("../kubernetes/network-policy");
7
10
  const UNITS = {
8
11
  n: 1e-9,
9
12
  u: 1e-6,
@@ -90,6 +93,17 @@ function why(what, e, rule) {
90
93
  }
91
94
  return `${what}: ${(0, log_1.getErrorMessage)(e)}`;
92
95
  }
96
+ // `why`, in a few words
97
+ function briefly(e, what) {
98
+ const code = (0, apply_1.statusCodeOf)(e);
99
+ if (code === 403) {
100
+ return what === 'nodes' ? 'may not list nodes' : 'may not read metrics';
101
+ }
102
+ if (code === 404 && what === 'metrics') {
103
+ return 'no metrics API';
104
+ }
105
+ return what === 'nodes' ? 'nodes not readable' : 'metrics not readable';
106
+ }
93
107
  const ENDED = ['Succeeded', 'Failed'];
94
108
  // On Kubernetes: per namespace of the server, its pods' requests, its quota and its usage.
95
109
  class KubernetesCapacity {
@@ -107,6 +121,37 @@ class KubernetesCapacity {
107
121
  catch (e) {
108
122
  problems.push(why('the namespaces of the server', e));
109
123
  }
124
+ const unknown = {};
125
+ // the runs by the value of their label, and what the pods of each run or sandbox take
126
+ const byLabel = new Map(runs.map((run) => [(0, job_placement_1.getRunLabelValue)(run.id), run]));
127
+ const uses = new Map();
128
+ const useOf = (namespace, labels, owner) => {
129
+ const run = byLabel.get(labels?.[job_placement_1.RUN_LABEL] ?? '');
130
+ const sandbox = labels?.[volumes_1.SANDBOX_LABEL] ?? labels?.[network_policy_1.PROXY_OF_SANDBOX_LABEL];
131
+ const key = run ? `run ${run.id}` : sandbox ? `sandbox ${sandbox}` : `other ${namespace}`;
132
+ let use = uses.get(key);
133
+ if (!use) {
134
+ use = run
135
+ ? {
136
+ kind: 'run',
137
+ run: run.id,
138
+ tasks: run.tasks,
139
+ ...(run.up ? { up: { name: run.up.name, services: run.up.services } } : {}),
140
+ repo: run.source.type === 'upload' ? 'an upload' : run.source.repo,
141
+ owner: { iss: run.owner.iss, sub: run.owner.sub, ...(run.owner.name ? { name: run.owner.name } : {}) },
142
+ pods: 0,
143
+ requested: { cpu: 0, memory: 0 },
144
+ }
145
+ : {
146
+ kind: sandbox ? 'sandbox' : 'other',
147
+ ...(owner ? { owner } : {}),
148
+ pods: 0,
149
+ requested: { cpu: 0, memory: 0 },
150
+ };
151
+ uses.set(key, use);
152
+ }
153
+ return use;
154
+ };
110
155
  const said = new Set();
111
156
  const once = (text) => {
112
157
  if (!said.has(text)) {
@@ -123,6 +168,8 @@ class KubernetesCapacity {
123
168
  // a namespace whose pods could be listed and that has none asks for nothing
124
169
  requested: { cpu: 0, memory: 0 },
125
170
  };
171
+ // the use of each pod that runs, by its name
172
+ const usesOfPods = new Map();
126
173
  try {
127
174
  const pods = await this.instance.coreApi.listNamespacedPod({ namespace });
128
175
  for (const pod of pods.items) {
@@ -130,9 +177,13 @@ class KubernetesCapacity {
130
177
  continue;
131
178
  }
132
179
  group.pods++;
180
+ const use = useOf(namespace, pod.metadata?.labels, group.owner);
181
+ usesOfPods.set(pod.metadata?.name ?? '', use);
182
+ use.pods++;
133
183
  for (const container of pod.spec?.containers ?? []) {
134
184
  const requests = container.resources?.requests ?? {};
135
185
  add(group.requested, parseQuantity(requests.cpu) ?? 0, parseQuantity(requests.memory) ?? 0);
186
+ add(use.requested, parseQuantity(requests.cpu) ?? 0, parseQuantity(requests.memory) ?? 0);
136
187
  }
137
188
  }
138
189
  }
@@ -162,13 +213,19 @@ class KubernetesCapacity {
162
213
  const metrics = await this.instance.metrics.getPodMetrics(namespace);
163
214
  const used = { cpu: 0, memory: 0 };
164
215
  for (const pod of metrics.items) {
216
+ const use = usesOfPods.get(pod.metadata?.name ?? '');
165
217
  for (const container of pod.containers) {
166
218
  add(used, parseQuantity(container.usage.cpu), parseQuantity(container.usage.memory));
219
+ if (use) {
220
+ use.used ?? (use.used = { cpu: 0, memory: 0 });
221
+ add(use.used, parseQuantity(container.usage.cpu), parseQuantity(container.usage.memory));
222
+ }
167
223
  }
168
224
  }
169
225
  group.used = used;
170
226
  }
171
227
  catch (e) {
228
+ unknown.used ?? (unknown.used = briefly(e, 'metrics'));
172
229
  once(why('what the pods use (metrics)', e, 'list on pods.metrics.k8s.io in them (runRole, see the backend docs)'));
173
230
  }
174
231
  }
@@ -204,11 +261,13 @@ class KubernetesCapacity {
204
261
  machines.used = nodesUsed;
205
262
  }
206
263
  catch (e) {
264
+ unknown.used ?? (unknown.used = briefly(e, 'metrics'));
207
265
  once(why('what the nodes use (metrics)', e, 'list on nodes.metrics.k8s.io (see the backend docs)'));
208
266
  }
209
267
  }
210
268
  }
211
269
  catch (e) {
270
+ unknown.total = briefly(e, 'nodes');
212
271
  // listing nodes is optional, and many clusters do not let a workload do it
213
272
  if ((0, apply_1.statusCodeOf)(e) === 403) {
214
273
  once('the nodes: not shown, the server may not list nodes (optional, see the backend docs)');
@@ -223,7 +282,11 @@ class KubernetesCapacity {
223
282
  ...(machines ? { machines } : {}),
224
283
  runs: total,
225
284
  groups,
285
+ uses: [...uses.values()].sort((a, b) => (b.used?.cpu ?? 0) - (a.used?.cpu ?? 0) ||
286
+ (b.requested.cpu ?? 0) - (a.requested.cpu ?? 0) ||
287
+ (b.requested.memory ?? 0) - (a.requested.memory ?? 0)),
226
288
  problems,
289
+ ...(Object.keys(unknown).length > 0 ? { unknown } : {}),
227
290
  };
228
291
  }
229
292
  }
@@ -5,12 +5,14 @@ const crypto_1 = require("crypto");
5
5
  const promises_1 = require("fs/promises");
6
6
  const path_1 = require("path");
7
7
  const http_1 = require("./http");
8
+ const oauth_1 = require("./oauth");
8
9
  const policy_1 = require("./policy");
9
10
  const overview_1 = require("./overview");
10
11
  // Approving and publishing take a sign-in of the last minutes: the groups of a session are
11
12
  // those of its sign-in, and somebody taken out of a group must not decide for hours.
12
13
  exports.DECIDE_WITHIN_MS = 15 * 60000;
13
14
  const RUN = 'run_[A-Za-z0-9_-]{16}';
15
+ const SANDBOX = 'sbx_[A-Za-z0-9_-]{16}';
14
16
  // a hash of a build file version, checked by the API
15
17
  const HASH = '[A-Za-z0-9:_-]{1,200}';
16
18
  const path = (pattern) => new RegExp(`^/v1/${pattern}$`);
@@ -28,9 +30,19 @@ const READS = [
28
30
  'proposals',
29
31
  `proposals/${HASH}`,
30
32
  `proposals/${HASH}/files`,
33
+ // the build files, and the page of the version a task or run ran
34
+ 'definitions',
35
+ `definitions/${HASH}`,
36
+ `definitions/${HASH}/files`,
37
+ // the person's sandboxes and what changed in them; not their files, nothing that writes
38
+ 'sandboxes',
39
+ `sandboxes/${SANDBOX}`,
40
+ `sandboxes/${SANDBOX}/changes`,
31
41
  ].map(path);
32
42
  // what the buttons do
33
- const ACTIONS = [`runs/${RUN}/(cancel|extend)`].map(path);
43
+ const ACTIONS = [`runs/${RUN}/(cancel|extend|wake)`].map(path);
44
+ // what the delete buttons remove: a sandbox of the person's
45
+ const REMOVALS = [`sandboxes/${SANDBOX}`].map(path);
34
46
  // what needs a recent sign-in
35
47
  const DECISIONS = [`approvals/${RUN}/(approve|reject)`, `proposals/${HASH}/(approve|reject)`].map(path);
36
48
  exports.HEADERS = {
@@ -52,8 +64,8 @@ const TYPES = {
52
64
  };
53
65
  async function handleDashboard(context, principal, req, res, url) {
54
66
  const now = context.now ?? Date.now;
55
- const grant = (0, policy_1.getGrant)(context.config.entitlements, principal);
56
- const viewer = { principal, grant, activity: context.config.runs.activity === 'everyone' };
67
+ const viewer = (0, overview_1.getViewer)(context.config, principal);
68
+ const { grant } = viewer;
57
69
  const pathname = url.pathname;
58
70
  for (const [name, value] of Object.entries(exports.HEADERS)) {
59
71
  res.setHeader(name, value);
@@ -78,9 +90,11 @@ async function api(context, principal, viewer, now, req, res, url) {
78
90
  if (method !== 'GET') {
79
91
  return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
80
92
  }
93
+ const name = (0, oauth_1.displayName)(principal);
81
94
  const session = {
82
95
  iss: principal.iss,
83
96
  sub: principal.sub,
97
+ ...(name ? { name } : {}),
84
98
  observe: grant.observe,
85
99
  seesOthers: grant.observe || (0, overview_1.seesActivity)(viewer),
86
100
  publishes: (0, policy_1.mayPublish)(grant),
@@ -103,15 +117,19 @@ async function api(context, principal, viewer, now, req, res, url) {
103
117
  const read = READS.some((pattern) => pattern.test(target));
104
118
  const action = ACTIONS.some((pattern) => pattern.test(target));
105
119
  const decision = DECISIONS.some((pattern) => pattern.test(target));
106
- if (!read && !action && !decision) {
120
+ const removal = REMOVALS.some((pattern) => pattern.test(target));
121
+ if (!read && !action && !decision && !removal) {
107
122
  return (0, http_1.send)(res, 404, { error: 'not found' });
108
123
  }
124
+ const allow = [read ? 'GET' : '', action || decision ? 'POST' : '', removal ? 'DELETE' : '']
125
+ .filter(Boolean)
126
+ .join(', ');
109
127
  if (method === 'GET' || method === 'HEAD') {
110
128
  if (!read) {
111
- return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
129
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow });
112
130
  }
113
131
  }
114
- else if (method === 'POST' && (action || decision)) {
132
+ else if ((method === 'POST' && (action || decision)) || (method === 'DELETE' && removal)) {
115
133
  // a page of another site can not set the header, nor read the token
116
134
  const given = Buffer.from(String(req.headers['x-hammerkit-csrf'] ?? ''));
117
135
  const expected = Buffer.from(csrf);
@@ -128,7 +146,7 @@ async function api(context, principal, viewer, now, req, res, url) {
128
146
  context.log(`dashboard ${method} ${target} iss=${principal.iss} sub=${principal.sub}`);
129
147
  }
130
148
  else {
131
- return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: read ? 'GET' : 'POST' });
149
+ return (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow });
132
150
  }
133
151
  const forwarded = new URL(`${target}${url.search}`, 'http://localhost');
134
152
  return context.api(principal, req, res, forwarded);
@@ -165,8 +183,8 @@ async function asset(context, req, res, pathname) {
165
183
  const root = context.assets ?? (0, path_1.join)(__dirname, '..', 'dashboard');
166
184
  const file = /^\/assets\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,200}$/.test(pathname)
167
185
  ? pathname.slice(1)
168
- : pathname === '/favicon.svg'
169
- ? 'favicon.svg'
186
+ : pathname === '/logo.png'
187
+ ? 'logo.png'
170
188
  : pathname.startsWith('/assets/') || pathname.startsWith('/.')
171
189
  ? undefined
172
190
  : 'index.html';
@@ -285,7 +285,15 @@ class EndpointGateway {
285
285
  return null;
286
286
  }
287
287
  const session = this.readSession(label, req.headers.cookie);
288
- return session ? { iss: session.iss, sub: session.sub, groups: session.groups, claims: session.claims } : null;
288
+ return session
289
+ ? {
290
+ iss: session.iss,
291
+ sub: session.sub,
292
+ groups: session.groups,
293
+ claims: session.claims,
294
+ ...(session.name ? { name: session.name } : {}),
295
+ }
296
+ : null;
289
297
  }
290
298
  // Whether the browser sends the request for another page than the endpoint's own: an
291
299
  // upgrade from another origin, or a request a page of another site makes (a frame too),
@@ -652,6 +660,7 @@ ${run.state === 'up'
652
660
  host: label,
653
661
  iss: issued.principal.iss,
654
662
  sub: issued.principal.sub,
663
+ ...((0, oauth_1.displayName)(issued.principal) ? { name: (0, oauth_1.displayName)(issued.principal) } : {}),
655
664
  groups: issued.principal.groups,
656
665
  claims: this.matchedClaims(issued.principal),
657
666
  exp,
@@ -220,6 +220,7 @@ class HostExecutor {
220
220
  let claim;
221
221
  // the files of the sandbox the tasks work on
222
222
  let work;
223
+ const sourceStarted = new Date();
223
224
  if (source.type === 'sandbox' && source.check) {
224
225
  // a check works on the files a push of the sandbox commits, copied out of it: never
225
226
  // on the sandbox, whose ignored files (node_modules, outputs) its owner writes
@@ -303,12 +304,17 @@ class HostExecutor {
303
304
  sandboxNetwork: network,
304
305
  };
305
306
  }
307
+ emitPhase(context, 'source', sourceStarted);
306
308
  const home = (0, path_1.join)(root, 'home');
307
309
  await (0, promises_1.mkdir)(home, { recursive: true });
308
310
  const cache = (0, path_1.join)(this.options.cache, cacheNamespace(run));
309
311
  await (0, promises_1.mkdir)(cache, { recursive: true });
310
312
  const placement = this.options.placement;
313
+ const namespaceStarted = new Date();
311
314
  const placed = placement ? await placement.prepare(run, context, { root: work ?? cwd, claim }) : undefined;
315
+ if (placed) {
316
+ emitPhase(context, 'namespace', namespaceStarted);
317
+ }
312
318
  try {
313
319
  await this.runInProcess(worker, {
314
320
  ...input,
@@ -504,4 +510,8 @@ class HostExecutor {
504
510
  }
505
511
  }
506
512
  exports.HostExecutor = HostExecutor;
513
+ // A step of a run before its tasks, for its timeline (RunPhase)
514
+ function emitPhase(context, phase, startedAt) {
515
+ context.emit({ type: 'phase', phase, startedAt: startedAt.toISOString(), finishedAt: new Date().toISOString() });
516
+ }
507
517
  //# sourceMappingURL=executor.js.map
@@ -1,7 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
3
+ exports.TokenVerifier = exports.quote = exports.TokenError = exports.displayName = void 0;
4
4
  const crypto_1 = require("crypto");
5
+ /**
6
+ * How pages name a person: the `name` of their token, else `preferred_username`, else `email`
7
+ * (with the scopes `profile email`); none with `openid` alone, which carries the subject only.
8
+ */
9
+ function displayName(principal) {
10
+ const claims = principal.claims ?? {};
11
+ for (const value of [principal.name, claims.name, claims.preferred_username, claims.email]) {
12
+ const trimmed = value?.trim();
13
+ if (trimmed) {
14
+ return trimmed.slice(0, 100);
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+ exports.displayName = displayName;
5
20
  // The reason is for the operator's log, never for the caller.
6
21
  class TokenError extends Error {
7
22
  }
@@ -5,6 +5,7 @@ const policy_1 = require("./policy");
5
5
  const runs_1 = require("./runs");
6
6
  const service_runs_1 = require("./service-runs");
7
7
  const repo_1 = require("./repo");
8
+ const units_1 = require("../utils/units");
8
9
  var wait_reason_1 = require("./wait-reason");
9
10
  Object.defineProperty(exports, "describeWait", { enumerable: true, get: function () { return wait_reason_1.describeWait; } });
10
11
  function getViewer(config, principal) {
@@ -12,6 +13,7 @@ function getViewer(config, principal) {
12
13
  principal,
13
14
  grant: (0, policy_1.getGrant)(config.entitlements, principal),
14
15
  activity: config.runs.activity === 'everyone',
16
+ nameOf: (owner) => (0, policy_1.subjectName)(config.entitlements, owner),
15
17
  };
16
18
  }
17
19
  exports.getViewer = getViewer;
@@ -21,9 +23,14 @@ function runActions(viewer, run) {
21
23
  }
22
24
  const mine = owns(viewer, run);
23
25
  const may = {
24
- ...(mine || decides(viewer, run) ? { stop: true } : {}),
26
+ ...(mine || decides(viewer, run) || (run.up && (0, service_runs_1.mayPublishEndpoints)(viewer.grant, run))
27
+ ? { stop: true }
28
+ : {}),
25
29
  ...(run.up && run.upAt && (mine || (0, service_runs_1.mayOpenEndpoints)(viewer.grant, run)) ? { extend: true } : {}),
26
30
  ...(mayDecide(viewer, run) ? { decide: true } : {}),
31
+ ...(run.up && run.state === 'asleep' && (mine || (0, service_runs_1.mayOpenEndpoints)(viewer.grant, run))
32
+ ? { wake: true }
33
+ : {}),
27
34
  };
28
35
  return Object.keys(may).length > 0 ? may : undefined;
29
36
  }
@@ -85,7 +92,8 @@ function mayRead(viewer, run) {
85
92
  return owns(viewer, run) || viewer.grant.observe || decides(viewer, run);
86
93
  }
87
94
  exports.mayRead = mayRead;
88
- function toListedRun(viewer, run, queue) {
95
+ // `entryOf` names where in its repository the build file of a version is.
96
+ function toListedRun(viewer, run, queue, entryOf) {
89
97
  const { owner, results, plan, options, up, error, ...rest } = run;
90
98
  const mine = owns(viewer, run);
91
99
  const may = runActions(viewer, run);
@@ -100,9 +108,11 @@ function toListedRun(viewer, run, queue) {
100
108
  ...(result.cache ? { cached: result.cache.hit } : {}),
101
109
  },
102
110
  ]));
111
+ const entry = entryOf?.(run.definition);
112
+ const name = owner.name ?? viewer.nameOf?.(owner);
103
113
  return {
104
114
  ...rest,
105
- ...(mine ? { plan, options } : { owner: { iss: owner.iss, sub: owner.sub } }),
115
+ ...(mine ? { plan, options } : { owner: { iss: owner.iss, sub: owner.sub, ...(name ? { name } : {}) } }),
106
116
  ...(up
107
117
  ? {
108
118
  up: {
@@ -116,6 +126,7 @@ function toListedRun(viewer, run, queue) {
116
126
  ...(queue ? { queue } : {}),
117
127
  ...(may ? { may } : {}),
118
128
  ...(!mine && mayRead(viewer, run) ? { readable: true } : {}),
129
+ ...(entry ? { entry } : {}),
119
130
  };
120
131
  }
121
132
  exports.toListedRun = toListedRun;
@@ -141,7 +152,8 @@ function queuePlace(registry, viewer, run) {
141
152
  return { position: queue.position, waitsFor: visibleReason(queue.waitsFor, visible) };
142
153
  }
143
154
  exports.queuePlace = queuePlace;
144
- function getOverview(snapshot, usage, viewer) {
155
+ function getOverview(snapshot, usage, viewer, entryOf) {
156
+ const list = (run, place) => toListedRun(viewer, run, place, entryOf);
145
157
  const runs = new Map();
146
158
  for (const run of [
147
159
  ...snapshot.running,
@@ -160,7 +172,7 @@ function getOverview(snapshot, usage, viewer) {
160
172
  return {
161
173
  ...place,
162
174
  mine: owns(viewer, entry.run),
163
- ...(maySee(viewer, entry.run) ? { run: toListedRun(viewer, entry.run, place) } : {}),
175
+ ...(maySee(viewer, entry.run) ? { run: list(entry.run, place) } : {}),
164
176
  };
165
177
  });
166
178
  const running = snapshot.running.filter((run) => maySee(viewer, run));
@@ -170,19 +182,19 @@ function getOverview(snapshot, usage, viewer) {
170
182
  places: {
171
183
  max: snapshot.places.max,
172
184
  used: snapshot.places.used,
173
- runs: running.map((run) => toListedRun(viewer, run)),
185
+ runs: running.map((run) => list(run)),
174
186
  others: snapshot.running.length - running.length,
175
187
  },
176
188
  queue,
177
189
  approvals: {
178
190
  waiting: snapshot.awaiting.runs.length,
179
191
  max: snapshot.awaiting.max,
180
- runs: snapshot.awaiting.runs.filter((run) => maySee(viewer, run)).map((run) => toListedRun(viewer, run)),
192
+ runs: snapshot.awaiting.runs.filter((run) => maySee(viewer, run)).map((run) => list(run)),
181
193
  },
182
194
  services: {
183
195
  open: snapshot.services.taken,
184
196
  ...(snapshot.services.max !== undefined ? { max: snapshot.services.max } : {}),
185
- runs: snapshot.services.runs.filter((run) => maySee(viewer, run)).map((run) => toListedRun(viewer, run)),
197
+ runs: snapshot.services.runs.filter((run) => maySee(viewer, run)).map((run) => list(run)),
186
198
  },
187
199
  limits: {
188
200
  waiting: meter(usage.waiting, limits?.waiting),
@@ -209,7 +221,7 @@ const STATES = [
209
221
  'rejected',
210
222
  ];
211
223
  // `scope`, `state` (a comma separated list) and `kind` of GET /v1/runs.
212
- function parseRunFilter(params, grant) {
224
+ function parseRunFilter(params, grant, now = Date.now()) {
213
225
  const scope = params.get('scope') ?? 'mine';
214
226
  if (scope !== 'mine' && scope !== 'visible' && scope !== 'all') {
215
227
  throw new Error('scope: mine, visible or all');
@@ -234,9 +246,35 @@ function parseRunFilter(params, grant) {
234
246
  if (id !== undefined && !/^run_[A-Za-z0-9_-]{16}$/.test(id)) {
235
247
  throw new Error('id: the id of a run, run_...');
236
248
  }
237
- return { scope, ...(states ? { states } : {}), ...(kind ? { kind } : {}), ...(id ? { id } : {}) };
249
+ const since = parseMoment(params.get('since'), now, 'since');
250
+ const until = parseMoment(params.get('until'), now, 'until');
251
+ if (since !== undefined && until !== undefined && until < since) {
252
+ throw new Error('until: after since');
253
+ }
254
+ return {
255
+ scope,
256
+ ...(states ? { states } : {}),
257
+ ...(kind ? { kind } : {}),
258
+ ...(id ? { id } : {}),
259
+ ...(since !== undefined ? { since } : {}),
260
+ ...(until !== undefined ? { until } : {}),
261
+ };
238
262
  }
239
263
  exports.parseRunFilter = parseRunFilter;
264
+ // `7d` back from now, or a time such as `2026-11-01T08:00:00Z`, in ms
265
+ function parseMoment(value, now, name) {
266
+ if (value === null) {
267
+ return undefined;
268
+ }
269
+ if (/^(\d+(\.\d+)?(ms|s|m|h|d))+$/.test(value)) {
270
+ return now - (0, units_1.parseDuration)(value);
271
+ }
272
+ const at = /^\d{4}-\d{2}-\d{2}/.test(value) ? Date.parse(value) : NaN;
273
+ if (!Number.isFinite(at)) {
274
+ throw new Error(`${name}: a duration back from now, such as 1h, 24h or 7d, or a time, such as 2026-11-01T08:00:00Z`);
275
+ }
276
+ return at;
277
+ }
240
278
  class FilterRefused extends Error {
241
279
  }
242
280
  exports.FilterRefused = FilterRefused;
@@ -244,7 +282,9 @@ function filterRuns(runs, viewer, filter) {
244
282
  return runs.filter((run) => (filter.scope === 'mine' ? owns(viewer, run) : maySee(viewer, run)) &&
245
283
  (!filter.id || run.id === filter.id) &&
246
284
  (!filter.states || filter.states.includes(run.state)) &&
247
- (!filter.kind || (filter.kind === 'services') === !!run.up));
285
+ (!filter.kind || (filter.kind === 'services') === !!run.up) &&
286
+ (filter.since === undefined || Date.parse(run.createdAt) >= filter.since) &&
287
+ (filter.until === undefined || Date.parse(run.createdAt) <= filter.until));
248
288
  }
249
289
  exports.filterRuns = filterRuns;
250
290
  // The machines and what the runs take, with whose the namespaces are where the caller may
@@ -255,6 +295,35 @@ function visibleCapacity(capacity, viewer) {
255
295
  ...capacity,
256
296
  // what the cluster answered is for operators; others learn that something is missing
257
297
  problems: viewer.grant.observe || capacity.problems.length === 0 ? capacity.problems : ['some of it could not be read'],
298
+ ...(capacity.unknown
299
+ ? {
300
+ unknown: everyone
301
+ ? capacity.unknown
302
+ : {
303
+ ...(capacity.unknown.total ? { total: 'not readable' } : {}),
304
+ ...(capacity.unknown.used ? { used: 'not readable' } : {}),
305
+ },
306
+ }
307
+ : {}),
308
+ // what others' runs and sandboxes take shows, not what they are
309
+ ...(capacity.uses
310
+ ? {
311
+ uses: capacity.uses.map((use) => {
312
+ const mine = use.owner?.iss === viewer.principal.iss && use.owner?.sub === viewer.principal.sub;
313
+ const name = use.owner && !use.owner.name ? viewer.nameOf?.(use.owner) : undefined;
314
+ return everyone || mine || !use.owner
315
+ ? name && use.owner
316
+ ? { ...use, owner: { ...use.owner, name } }
317
+ : use
318
+ : {
319
+ kind: use.kind,
320
+ pods: use.pods,
321
+ requested: use.requested,
322
+ ...(use.used ? { used: use.used } : {}),
323
+ };
324
+ }),
325
+ }
326
+ : {}),
258
327
  groups: capacity.groups.map((group) => {
259
328
  const mine = group.owner?.iss === viewer.principal.iss && group.owner?.sub === viewer.principal.sub;
260
329
  if (everyone || mine || !group.owner) {
@@ -294,6 +294,13 @@ function describeMountSource(mount) {
294
294
  const home = (0, normalize_path_1.getHomeReference)(mount.mount.split(':')[0]);
295
295
  return home ? `${mount.localPath} (${home} is the home directory)` : mount.localPath;
296
296
  }
297
+ // where the build file of an item is: in the bundle, or in the repository of a git include
298
+ function declaredIn(root, scope) {
299
+ if (scope.remote) {
300
+ return `${scope.remote.git} ${(0, path_1.relative)(scope.remote.root, scope.fileName).split(path_1.sep).join('/')}`;
301
+ }
302
+ return (0, path_1.relative)(root, scope.fileName).split(path_1.sep).join('/');
303
+ }
297
304
  function toPlan(root, item) {
298
305
  const data = item.data;
299
306
  const secrets = secretsOf(item).map(describeSecret);
@@ -322,6 +329,7 @@ function toPlan(root, item) {
322
329
  const isTask = data.type === 'local-task' || data.type === 'container-task';
323
330
  return {
324
331
  kind: isTask ? 'task' : 'service',
332
+ file: declaredIn(root, data.scope),
325
333
  ...(data.description ? { description: data.description } : {}),
326
334
  runtime: (0, work_task_1.isAggregateTask)(data) ? 'none' : data.type === 'local-task' ? 'local' : 'docker',
327
335
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
@@ -330,13 +338,18 @@ function toPlan(root, item) {
330
338
  network: (0, work_network_1.toNetworkSchema)('network' in data ? data.network : { type: 'open' }),
331
339
  ...('args' in data && data.args !== null ? { args: data.args } : {}),
332
340
  deps: item.deps.map((dep) => dep.name).sort(),
333
- needs: item.needs.map((need) => need.name).sort(),
341
+ // by their names in the plan, not the names the task reaches them at (`postgres` of an include)
342
+ needs: item.needs.map((need) => need.service.name).sort(),
334
343
  accounts,
335
344
  secrets,
336
345
  serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
337
346
  mounts,
338
347
  ports,
339
- generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
348
+ // where in the bundle, as the build file names them: the directory the server read it in is
349
+ // its own
350
+ generates: 'generates' in data
351
+ ? data.generates.map((generate) => insideBundle(root, data.cwd, generate.path)[0] ?? generate.path)
352
+ : [],
340
353
  ...(capAdd.length > 0 ? { capAdd: [...capAdd] } : {}),
341
354
  flags,
342
355
  };
@@ -400,7 +413,7 @@ async function planBundle(bundle, environment, options = {}) {
400
413
  ? item.data.mounts.flatMap((mount) => insideBundle(directory, item.data.cwd, mount.localPath))
401
414
  : [],
402
415
  deps: item.deps.map((dep) => dep.name).sort(),
403
- needs: item.needs.map((need) => need.name).sort(),
416
+ needs: item.needs.map((need) => need.service.name).sort(),
404
417
  };
405
418
  }
406
419
  const plan = {};
@@ -1,8 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayReadChecks = exports.mayRun = exports.matches = void 0;
3
+ exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayReadChecks = exports.mayRun = exports.matches = exports.subjectName = void 0;
4
4
  const repo_1 = require("./repo");
5
5
  const BUILDS_ORDER = ['run', 'propose', 'publish'];
6
+ // The name an entitlement gives a subject, for the pages to name its runs by when its tokens
7
+ // name nobody.
8
+ function subjectName(entitlements, who) {
9
+ return entitlements.find((entitlement) => entitlement.subject?.name && entitlement.subject.iss === who.iss && entitlement.subject.sub === who.sub)?.subject?.name;
10
+ }
11
+ exports.subjectName = subjectName;
6
12
  function matches(entitlement, principal) {
7
13
  if (entitlement.subject) {
8
14
  return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
@@ -34,9 +40,9 @@ const mayPublish = (grant) => atLeast(grant, 'publish');
34
40
  exports.mayPublish = mayPublish;
35
41
  // A caller who can neither run nor publish anything sees nothing of the server's
36
42
  // build files either. Someone who uses an account sees them: they have to know what
37
- // they approve.
43
+ // they approve. Who observes sees them too, as they see what every run ran.
38
44
  function maySeeDefinitions(grant) {
39
- return grant.builds !== null || Object.keys(grant.accounts).length > 0;
45
+ return grant.builds !== null || Object.keys(grant.accounts).length > 0 || grant.observe;
40
46
  }
41
47
  exports.maySeeDefinitions = maySeeDefinitions;
42
48
  // `use` includes deciding about the runs others request on the account.