hammerkit 1.27.0-next.15 → 1.27.0-next.17

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.
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.computeStateKey = exports.combineStateKeys = exports.resolveEffective = void 0;
4
- const crypto_1 = require("crypto");
3
+ exports.computeStateKey = exports.combineStateKeys = exports.isOwnProvable = exports.resolveEffective = void 0;
5
4
  const get_work_cache_stats_1 = require("../../optimizer/get-work-cache-stats");
6
5
  // The state-key engine is shared by the scheduler's hit/miss decision
7
6
  // (`checkCacheState`) and the read-only cache-explain engine, so a prediction
@@ -24,6 +23,7 @@ function isOwnProvable(item, unmatched) {
24
23
  // unmatched ⊆ declared, so this is also false when nothing is declared
25
24
  return unmatched.length < item.data.src.filter((src) => !src.inherited).length;
26
25
  }
26
+ exports.isOwnProvable = isOwnProvable;
27
27
  // Fold a task's own state key together with the state keys of its dependencies.
28
28
  // A change anywhere in the dependency subtree must change the resulting key, so
29
29
  // that a downstream task is invalidated when an upstream source changes — even
@@ -34,9 +34,7 @@ function combineStateKeys(ownKey, depKeys) {
34
34
  }
35
35
  // Sort so the key is independent of dependency declaration order.
36
36
  const sorted = [...depKeys].sort();
37
- return (0, crypto_1.createHash)('md5')
38
- .update([ownKey, ...sorted].join(','))
39
- .digest('hex');
37
+ return (0, get_work_cache_stats_1.stateKeyDigest)([ownKey, ...sorted].join(','));
40
38
  }
41
39
  exports.combineStateKeys = combineStateKeys;
42
40
  // `none` only disables the cache *check* for a task; its sources still influence
@@ -72,7 +70,41 @@ async function computeStateKey(item, defaultCacheMethod, environment, memo = new
72
70
  depKeys.push(effective.stateKey);
73
71
  provable = provable && effective.provable;
74
72
  }
73
+ // what a task that needs a service passed against: the sources of the service and of the
74
+ // tasks it depends on (its definition is in the task id, getWorkServiceCacheIdentity)
75
+ for (const need of item.needs) {
76
+ const effective = await resolveNeedStateKey(need.service, defaultCacheMethod, environment, memo);
77
+ depKeys.push(`${need.name}=${effective.stateKey}`);
78
+ }
75
79
  return { stateKey: combineStateKeys(ownKey, depKeys), stats, resolved, provable, unmatched };
76
80
  }
77
81
  exports.computeStateKey = computeStateKey;
82
+ // A needed service's sources folded with the state keys of its dependencies and needs. It
83
+ // does not make the task that needs it unprovable: a dependency without `src` (resetting the
84
+ // service's data, say) is skipped with the service when the task is a cache hit.
85
+ async function resolveNeedStateKey(service, defaultCacheMethod, environment, memo) {
86
+ const memoKey = `need\0${service.name}`;
87
+ const existing = memo.get(memoKey);
88
+ if (existing !== undefined) {
89
+ return existing;
90
+ }
91
+ // services that need each other in a loop fail to plan (checkForLoop): this only has to end
92
+ memo.set(memoKey, { stateKey: 'loop', provable: true });
93
+ const resolved = resolveEffective(service, defaultCacheMethod);
94
+ const { stats } = await (0, get_work_cache_stats_1.getWorkItemCacheStats)(service, environment);
95
+ const keys = [];
96
+ for (const dep of service.deps) {
97
+ keys.push((await resolveEffectiveStateKey(dep, defaultCacheMethod, environment, memo)).stateKey);
98
+ }
99
+ for (const need of service.needs) {
100
+ const effective = await resolveNeedStateKey(need.service, defaultCacheMethod, environment, memo);
101
+ keys.push(`${need.name}=${effective.stateKey}`);
102
+ }
103
+ const effective = {
104
+ stateKey: combineStateKeys((0, get_work_cache_stats_1.getStateKey)(stats, concreteMethod(resolved, defaultCacheMethod)), keys),
105
+ provable: true,
106
+ };
107
+ memo.set(memoKey, effective);
108
+ return effective;
109
+ }
78
110
  //# sourceMappingURL=state-key.js.map
@@ -35,7 +35,7 @@ function sandboxPath(path) {
35
35
  * of `run_task` lists; without `shell` there is no `bash`.
36
36
  */
37
37
  function createMcpTools(client, options = {}) {
38
- const [runTask, runLogs] = (0, task_tools_1.createTaskTools)(options.tasks, options.shell || undefined).map((tool) => fromTaskTool(tool, client));
38
+ const [runTask, runLogs, check] = (0, task_tools_1.createTaskTools)(options.tasks, options.shell || undefined).map((tool) => fromTaskTool(tool, client));
39
39
  const bash = {
40
40
  name: 'bash',
41
41
  description: 'Run a shell command on the sandbox and answer its output and exit code. It runs as the shell task of ' +
@@ -196,6 +196,7 @@ function createMcpTools(client, options = {}) {
196
196
  },
197
197
  ...(options.shell === false ? [] : [bash]),
198
198
  runTask,
199
+ check,
199
200
  { ...runLogs, annotations: { ...runLogs.annotations, ...READ_ONLY } },
200
201
  {
201
202
  name: 'changes',
@@ -65,6 +65,7 @@ exports.OPENCODE_TOOLS = [
65
65
  'glob',
66
66
  'run_task',
67
67
  'run_logs',
68
+ 'check',
68
69
  'push',
69
70
  'list',
70
71
  'services_up',
@@ -154,6 +155,7 @@ exports.OPENCODE_V2_TOOLS = [
154
155
  'grep',
155
156
  'run_task',
156
157
  'run_logs',
158
+ 'check',
157
159
  'push',
158
160
  'services_up',
159
161
  'endpoint_get',
@@ -270,7 +272,7 @@ function createOpencodeV2Tools(client, directory, options = {}) {
270
272
  caseSensitive: flag('Match case (default true)'),
271
273
  limit: count(`The maximum number of matching lines (default ${SEARCH_LIMIT})`),
272
274
  }, ['pattern']), same),
273
- ...['run_task', 'run_logs'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
275
+ ...['run_task', 'run_logs', 'check'].map((name) => tool(name, name, { ...(0, task_tools_1.taskToolSchema)(taskTools[name]), additionalProperties: false }, same)),
274
276
  tool('push', 'push', object({
275
277
  branch: text('The branch to push to, created when it does not exist'),
276
278
  message: text('The commit message'),
@@ -317,7 +319,12 @@ function opencodeTools(client, options = {}) {
317
319
  args: (z) => (0, task_tools_1.taskToolArgs)(task, z),
318
320
  async execute(args, context) {
319
321
  const output = await task.call(await client(), args, context.abort);
320
- return { title: task.name === 'run_task' ? `run ${args.task}` : `log of ${args.run}`, output };
322
+ return {
323
+ title: task.name === 'run_logs'
324
+ ? `log of ${args.run}`
325
+ : `${task.name === 'check' ? 'check' : 'run'} ${args.task}`,
326
+ output,
327
+ };
321
328
  },
322
329
  },
323
330
  ]));
@@ -192,6 +192,21 @@ function hammerkitPiExtension(library, settings = {}) {
192
192
  },
193
193
  });
194
194
  }
195
+ register({
196
+ name: 'check',
197
+ label: 'check',
198
+ description: sandbox_client_1.CHECK_DESCRIPTION,
199
+ promptSnippet: 'Run a task as a check that CI verifies after the push',
200
+ parameters: {
201
+ type: 'object',
202
+ properties: { task: { type: 'string', description: 'The name of the task' } },
203
+ required: ['task'],
204
+ },
205
+ execute: async (_id, params, signal) => {
206
+ const text = await (0, sandbox_client_1.runCheck)(await client(), params.task, signal);
207
+ return { content: [{ type: 'text', text }], details: { task: params.task } };
208
+ },
209
+ });
195
210
  pi.on('session_start', async (_event, ctx) => {
196
211
  try {
197
212
  const sandbox = await client();
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.SandboxClientError = void 0;
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.listPublishedTasks = exports.runCheck = exports.CHECK_DESCRIPTION = exports.SandboxClientError = void 0;
4
4
  const path_1 = require("path");
5
5
  const token_source_1 = require("../remote/token-source");
6
6
  // a write carries up to 32 MiB
@@ -16,6 +16,30 @@ class SandboxClientError extends Error {
16
16
  }
17
17
  }
18
18
  exports.SandboxClientError = SandboxClientError;
19
+ // What the agent is told of a check, on each harness.
20
+ exports.CHECK_DESCRIPTION = 'Run a task as a check before you push, such as test or e2e: it runs on a copy of the files a push ' +
21
+ 'would commit (what .gitignore leaves out is not there, so dependencies are installed by the tasks ' +
22
+ 'the build file names), and when it passes, CI verifies it for that commit instead of running it. ' +
23
+ 'Push without changing files afterwards, or the check no longer counts for the files that changed.';
24
+ // A check, run to its end, in a few lines: how it went and the last lines of a failure.
25
+ async function runCheck(client, task, signal) {
26
+ const ran = await client.runTask(task, { check: true, signal });
27
+ const result = ran.result;
28
+ const lines = [
29
+ result?.status === 'succeeded' || result?.status === 'cached'
30
+ ? `check ${task} passed (run ${ran.run.id}${result.status === 'cached' ? ', from the cache' : ''}): CI can verify it for a push of these files`
31
+ : `check ${task} did not pass (run ${ran.run.id}: ${result?.status ?? ran.run.state}${result?.exitCode !== undefined ? `, exit code ${result.exitCode}` : ''})`,
32
+ ];
33
+ const error = result?.error ?? ran.run.error;
34
+ if (error) {
35
+ lines.push(`error: ${error}`);
36
+ }
37
+ if (result && result.status !== 'succeeded' && result.status !== 'cached' && result.lastLog.length > 0) {
38
+ lines.push('last lines:', ...result.lastLog);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+ exports.runCheck = runCheck;
19
43
  /**
20
44
  * The tasks of a published version, without its services, as `GET /v1/definitions/<hash>`
21
45
  * answers them.
@@ -143,7 +167,7 @@ class SandboxClient {
143
167
  const submitted = await submitRun(this.connection, {
144
168
  definition: this.build.hash,
145
169
  tasks: [task],
146
- source: { type: 'sandbox', id: this.id },
170
+ source: { type: 'sandbox', id: this.id, ...(options.check ? { check: true } : {}) },
147
171
  options: runOptions,
148
172
  });
149
173
  const followed = await followRun(this.connection, submitted, {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.taskToolArgs = exports.taskToolSchema = exports.createTaskTools = exports.describeTasks = exports.describeRun = exports.readLogHint = exports.readTaskListing = exports.LIST_TASKS_MS = void 0;
4
+ const sandbox_client_1 = require("./sandbox-client");
4
5
  // the most tasks a description lists, and of a task's description or command it shows
5
6
  const MAX_LISTED = 40;
6
7
  const MAX_DESCRIPTION_CHARS = 200;
@@ -100,7 +101,7 @@ function cut(text, max) {
100
101
  return text.length > max ? `${text.slice(0, max)}…` : text;
101
102
  }
102
103
  /**
103
- * `run_task` and `run_logs`, with the tasks the description of `run_task` lists. Without a
104
+ * `run_task`, `run_logs` and `check`, with the tasks the description of `run_task` lists. Without a
104
105
  * listing it names none, as the MCP tools before a version could be read.
105
106
  */
106
107
  function createTaskTools(listing, shell) {
@@ -160,6 +161,16 @@ function createTaskTools(listing, shell) {
160
161
  return lines.join('\n');
161
162
  },
162
163
  },
164
+ {
165
+ name: 'check',
166
+ title: 'Run a check',
167
+ description: sandbox_client_1.CHECK_DESCRIPTION,
168
+ parameters: { task: { type: 'string', description: 'The name of the task' } },
169
+ required: ['task'],
170
+ call(client, args, signal) {
171
+ return (0, sandbox_client_1.runCheck)(client, args.task, signal);
172
+ },
173
+ },
163
174
  ];
164
175
  }
165
176
  exports.createTaskTools = createTaskTools;
@@ -4,9 +4,10 @@ exports.calculateChecksum = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  // Hashes the raw bytes: decoding to text first would map every invalid UTF-8
6
6
  // sequence to U+FFFD, so two different binary files could share a checksum.
7
- // For UTF-8 text the result is unchanged.
7
+ // For UTF-8 text the result is unchanged. sha256, which nobody can make two files share:
8
+ // a cache hit is also what a verified check counts (check-key.ts).
8
9
  async function calculateChecksum(environment, path) {
9
- const hash = (0, crypto_1.createHash)('sha1');
10
+ const hash = (0, crypto_1.createHash)('sha256');
10
11
  for await (const chunk of environment.file.readStream(path)) {
11
12
  hash.update(chunk);
12
13
  }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCheckKey = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const state_key_1 = require("../executer/scheduler/state-key");
6
+ const get_work_cache_stats_1 = require("./get-work-cache-stats");
7
+ const work_task_cache_description_1 = require("./work-task-cache-description");
8
+ /**
9
+ * What a passed check is recorded under, and what `remote verify` looks it up by: the
10
+ * inputs of the task's cache key, so a cached result counts for the same key it was
11
+ * stored under. Its definition (the services it needs included), the files of its `src`,
12
+ * the keys of its dependencies, and the sources of the services it needs and of the tasks
13
+ * they depend on.
14
+ *
15
+ * It is computed on a checkout before anything ran in it, on both ends, so it also covers
16
+ * the files a commit holds where a dependency generates (the cache leaves those out, as
17
+ * they change once the dependency ran), and it leaves out `.git`, which is no file of a
18
+ * commit. Two checkouts of the same files give the same key wherever they are.
19
+ * Null for a task that can not be proven up to date (no `src` of its own, or none that
20
+ * matches a file, here or in a dependency): its result says nothing about any files.
21
+ */
22
+ async function getCheckKey(item, environment, memo = new Map(), path = new Set()) {
23
+ return memoized(memo, path, `task\0${item.id()}`, (inner) => computeCheckKey(item, environment, memo, inner));
24
+ }
25
+ exports.getCheckKey = getCheckKey;
26
+ async function computeCheckKey(item, environment, memo, path) {
27
+ const { stats, unmatched } = await (0, get_work_cache_stats_1.getWorkItemCheckoutStats)(item, environment);
28
+ if (!(0, state_key_1.isOwnProvable)(item, unmatched)) {
29
+ return null;
30
+ }
31
+ const inputs = await getInputKeys(item, environment, memo, path);
32
+ if (inputs === null) {
33
+ return null;
34
+ }
35
+ return hash({ task: (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item), files: commitFiles(stats), ...inputs });
36
+ }
37
+ // The sources of a needed service, with the keys of what it depends on and needs; its
38
+ // definition is in the description of the task that needs it.
39
+ function getNeedKey(item, environment, memo, path) {
40
+ return memoized(memo, path, `need\0${item.name}`, async (inner) => {
41
+ const inputs = await getInputKeys(item, environment, memo, inner);
42
+ if (inputs === null) {
43
+ return null;
44
+ }
45
+ return hash({ files: commitFiles(await (0, get_work_cache_stats_1.getWorkCacheStats)(item.data, environment)), ...inputs });
46
+ });
47
+ }
48
+ async function getInputKeys(item, environment, memo, path) {
49
+ const deps = [];
50
+ for (const dep of item.deps) {
51
+ const key = await getCheckKey(dep, environment, memo, path);
52
+ if (key === null) {
53
+ return null;
54
+ }
55
+ deps.push(key);
56
+ }
57
+ const needs = [];
58
+ for (const need of item.needs) {
59
+ const key = await getNeedKey(need.service, environment, memo, path);
60
+ if (key === null) {
61
+ return null;
62
+ }
63
+ needs.push(`${need.name}=${key}`);
64
+ }
65
+ return { deps: deps.sort(), needs: needs.sort() };
66
+ }
67
+ // a commit never holds `.git`: what a checkout keeps there differs between a CI job's clone,
68
+ // the server's fetch and a sandbox's copy, which has none
69
+ function commitFiles(stats) {
70
+ return Object.keys(stats.files)
71
+ .filter((path) => !path.split('/').includes('.git'))
72
+ .sort()
73
+ .map((path) => [path, stats.files[path].checksum]);
74
+ }
75
+ function hash(value) {
76
+ return (0, crypto_1.createHash)('sha256').update(JSON.stringify(value)).digest('hex');
77
+ }
78
+ // `path` holds the keys being computed on the way here: one coming back is a loop (services
79
+ // that need each other), which can not be proven and fails to plan anyway. Waiting on its
80
+ // pending promise would never end. The keys are computed one after another, so every pending
81
+ // promise in the memo is on the path.
82
+ function memoized(memo, path, key, compute) {
83
+ if (path.has(key)) {
84
+ return Promise.resolve(null);
85
+ }
86
+ const known = memo.get(key);
87
+ if (known) {
88
+ return known;
89
+ }
90
+ const computed = compute(new Set([...path, key]));
91
+ memo.set(key, computed);
92
+ return computed;
93
+ }
94
+ //# sourceMappingURL=check-key.js.map
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCheckKeys = void 0;
4
+ const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
5
+ const reference_parser_1 = require("../schema/reference-parser");
6
+ const schema_parser_1 = require("../schema/schema-parser");
7
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
8
+ const log_1 = require("../log");
9
+ const check_key_1 = require("./check-key");
10
+ /**
11
+ * The check keys of the tasks of a scope, as both ends compute them: the process of a run on
12
+ * the server before it executes anything, and `remote verify` in a CI job's checkout. So the
13
+ * build file is read the same way on both, as a server reads it (no `.env`, links are files)
14
+ * but placed nowhere: where a server runs a task (a pool, its tool image, the arch of its
15
+ * nodes) is the server's, and a result counts for the server it passed on. The secrets'
16
+ * providers and accounts are the server's too, and no part of a key, so any is taken here.
17
+ */
18
+ async function getCheckKeys(entry, environment, scope) {
19
+ const keyed = { ...environment, hosted: { catalog: anyCatalog(), localTasksInContainers: true } };
20
+ const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(entry, keyed);
21
+ const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, keyed), scope, keyed);
22
+ const memo = new Map();
23
+ const keys = {};
24
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
25
+ try {
26
+ const key = await (0, check_key_1.getCheckKey)(task, keyed, memo);
27
+ keys[task.name] = key
28
+ ? { key }
29
+ : {
30
+ key: null,
31
+ reason: 'it, or a task it or a service it needs depends on, declares no src that matches a file',
32
+ };
33
+ }
34
+ catch (e) {
35
+ // a secret whose value is part of the key (`cache: true`) is not known here
36
+ keys[task.name] = { key: null, reason: (0, log_1.getErrorMessage)(e) };
37
+ }
38
+ }
39
+ return keys;
40
+ }
41
+ exports.getCheckKeys = getCheckKeys;
42
+ // Every provider and account name resolves, to a definition nothing runs.
43
+ function anyCatalog() {
44
+ const provider = (name) => ({
45
+ name,
46
+ type: 'command',
47
+ command: [],
48
+ env: {},
49
+ timeoutMs: 0,
50
+ declaredIn: 'the server',
51
+ });
52
+ const account = (name) => ({
53
+ name,
54
+ default: false,
55
+ providers: new Proxy({}, { get: () => ({ env: {} }) }),
56
+ declaredIn: 'the server',
57
+ });
58
+ return {
59
+ providers: new Proxy({}, { get: (_target, name) => (typeof name === 'string' ? provider(name) : undefined) }),
60
+ accounts: new Proxy({}, { get: (_target, name) => (typeof name === 'string' ? account(name) : undefined) }),
61
+ explicitAccounts: true,
62
+ };
63
+ }
64
+ //# sourceMappingURL=check-keys.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getStateKey = exports.getWorkItemCacheStats = exports.getWorkCacheStats = void 0;
3
+ exports.stateKeyDigest = exports.getStateKey = exports.getWorkItemCheckoutStats = exports.getWorkItemCacheStats = exports.getWorkCacheStats = void 0;
4
4
  const calculate_checksum_1 = require("./calculate-checksum");
5
5
  const path_1 = require("path");
6
6
  const crypto_1 = require("crypto");
@@ -20,7 +20,7 @@ async function addWorkCacheStats(result, cwd, path, matcher, context, root, excl
20
20
  if (!matcher(path)) {
21
21
  return 0;
22
22
  }
23
- const checksum = (0, crypto_1.createHash)('sha1')
23
+ const checksum = (0, crypto_1.createHash)('sha256')
24
24
  .update(`link:${await (0, promises_1.readlink)(path)}`)
25
25
  .digest('hex');
26
26
  result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: link.mtimeMs, checksum };
@@ -85,6 +85,13 @@ function getWorkItemCacheStats(item, environment) {
85
85
  return collectSourceStats(item.data, environment, dependencyOutputs(item));
86
86
  }
87
87
  exports.getWorkItemCacheStats = getWorkItemCacheStats;
88
+ // The source stats of a task with what its dependencies generate: on a checkout nothing ran
89
+ // in, those are the files a commit holds there, see check-key.ts.
90
+ async function getWorkItemCheckoutStats(item, environment) {
91
+ const { stats } = await collectSourceStats(item.data, environment, []);
92
+ return { stats, unmatched: (await getWorkItemCacheStats(item, environment)).unmatched };
93
+ }
94
+ exports.getWorkItemCheckoutStats = getWorkItemCheckoutStats;
88
95
  function dependencyOutputs(item) {
89
96
  const outputs = new Set();
90
97
  const visited = new Set();
@@ -113,7 +120,14 @@ function getStateKey(stats, cacheMethod) {
113
120
  contents.push(`${key}:${stats.files[key].lastModified}`);
114
121
  }
115
122
  }
116
- return (0, crypto_1.createHash)('md5').update(contents.join(',')).digest('hex');
123
+ return stateKeyDigest(contents.join(','));
117
124
  }
118
125
  exports.getStateKey = getStateKey;
126
+ // A state key is also the value of the `hammerkit.dev/state` label on Kubernetes, which
127
+ // takes at most 63 characters: the sha256 in base 36 has 50.
128
+ function stateKeyDigest(content) {
129
+ const hex = (0, crypto_1.createHash)('sha256').update(content).digest('hex');
130
+ return BigInt(`0x${hex}`).toString(36).padStart(50, '0');
131
+ }
132
+ exports.stateKeyDigest = stateKeyDigest;
119
133
  //# sourceMappingURL=get-work-cache-stats.js.map
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.storedCacheDescription = exports.getWorkTaskCacheDescription = void 0;
3
+ exports.storedCacheDescription = exports.getWorkServiceCacheIdentity = exports.getWorkTaskCacheDescription = void 0;
4
4
  const os_1 = require("os");
5
5
  const crypto_1 = require("crypto");
6
6
  const replace_env_variables_1 = require("../environment/replace-env-variables");
7
7
  const portable_path_1 = require("../planner/utils/portable-path");
8
8
  const work_secret_1 = require("../planner/work-secret");
9
9
  const claim_layout_1 = require("../kubernetes/claim-layout");
10
+ const work_service_1 = require("../planner/work-service");
11
+ const work_service_id_1 = require("../planner/work-service-id");
12
+ const work_cache_id_1 = require("../planner/work-cache-id");
10
13
  // Every path is made project-relative (portablePath) so the description, and the
11
14
  // task id hashed from it, is identical wherever the project is checked out.
12
15
  //
@@ -42,9 +45,54 @@ function getWorkTaskCacheDescription(item) {
42
45
  deps: item.deps.length > 0 ? item.deps.map((dep) => dep.id()).sort() : undefined,
43
46
  secrets: cachedSecrets.length > 0 ? cachedSecrets : undefined,
44
47
  claimLayout: (0, claim_layout_1.hasPathsEndingAlike)(item) || (0, claim_layout_1.hasOutputsFlatteningAlike)(item) ? 2 : undefined,
48
+ needs: item.needs.length > 0 ? getNeedsIdentities(item) : undefined,
45
49
  };
46
50
  }
47
51
  exports.getWorkTaskCacheDescription = getWorkTaskCacheDescription;
52
+ // `visiting` holds the services on the way here: services that need each other in a loop
53
+ // fail to plan (checkForLoop), the identity only has to come back to say so
54
+ function getNeedsIdentities(item, visiting = new Set()) {
55
+ return (item.needs
56
+ // an init task needs the service it initializes, whose identity holds the init's id
57
+ .filter((need) => !isInitOf(item, need.service))
58
+ .map((need) => visiting.has(need.service)
59
+ ? `${need.name}=loop`
60
+ : `${need.name}=${getWorkServiceCacheIdentity(need.service, visiting)}`)
61
+ .sort());
62
+ }
63
+ function isInitOf(item, service) {
64
+ return (0, work_service_1.isContainerWorkService)(service.data) && service.data.init?.task.data === item.data;
65
+ }
66
+ // Everything that makes a service what a task needing it talks to: its definition, the ids of
67
+ // the tasks it depends on and of its init task, and the services it needs in turn. The source
68
+ // of those tasks and of the service is in the state key (computeStateKey), like a dependency's.
69
+ // Not the service id, which leaves out what a service runs (getWorkServiceId).
70
+ function getWorkServiceCacheIdentity(item, visiting = new Set()) {
71
+ const service = item.data;
72
+ const definition = (0, work_service_1.isContainerWorkService)(service)
73
+ ? {
74
+ cmd: service.cmd?.cmd ?? null,
75
+ envs: (0, replace_env_variables_1.getEnvironmentVariables)(service.envs),
76
+ healthcheck: service.healthcheck?.cmd.cmd ?? null,
77
+ secrets: service.secrets
78
+ .filter((secret) => secret.cache)
79
+ .map((secret) => `${(0, work_secret_1.getPortableSecretName)(service.projectRoot, secret)}=${secret.digest()}`)
80
+ .sort(),
81
+ // as declared: arguments after -- of a run of the init leave the service as it is
82
+ init: service.init ? (0, work_cache_id_1.getDeclaredWorkTaskId)(service.init.task) : null,
83
+ }
84
+ : { namespace: service.namespace };
85
+ return (0, crypto_1.createHash)('sha256')
86
+ .update(JSON.stringify({
87
+ id: (0, work_service_id_1.getWorkServiceId)(service),
88
+ ...definition,
89
+ ports: service.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`).sort(),
90
+ deps: item.deps.map((dep) => dep.id()).sort(),
91
+ needs: getNeedsIdentities(item, new Set([...visiting, item])),
92
+ }))
93
+ .digest('hex');
94
+ }
95
+ exports.getWorkServiceCacheIdentity = getWorkServiceCacheIdentity;
48
96
  // The form written to disk: description.json in a cache entry (pushed to remote
49
97
  // backends, readable by everyone who can pull) and the local explain record.
50
98
  // Env is where tokens get passed, so each value is stored as a digest; the task
@@ -16,7 +16,19 @@ const sandbox_client_1 = require("../harness/sandbox-client");
16
16
  const units_1 = require("../utils/units");
17
17
  const service_tools_1 = require("../harness/service-tools");
18
18
  const path_1 = require("path");
19
+ const check_keys_1 = require("../optimizer/check-keys");
19
20
  const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
21
+ // how a check passed, one line
22
+ function describePassed(passed) {
23
+ const from = passed.source.type === 'ref'
24
+ ? `${passed.source.ref} at ${passed.source.commit.slice(0, 12)}`
25
+ : `a check of sandbox ${passed.source.sandbox}, fetched at ${passed.source.commit.slice(0, 12)}`;
26
+ return `passed ${passed.finishedAt}${passed.status === 'cached'
27
+ ? ' from the cache'
28
+ : passed.durationMs !== undefined
29
+ ? ` in ${(0, units_1.formatDuration)(passed.durationMs)}`
30
+ : ''}, ${passed.run} by ${passed.by.sub}, from ${from}`;
31
+ }
20
32
  // what the publisher said about where a version comes from, one line
21
33
  // What a publisher said about where a version comes from: the server takes it as said,
22
34
  // so anybody else is told it is not checked.
@@ -158,6 +170,7 @@ function registerWorkCommands(remote, context) {
158
170
  .addOption(new commander_1.Option('--cache <method>', 'as --cache of run').choices(['checksum', 'modify-date', 'none']))
159
171
  .addOption(new commander_1.Option('--timeout <duration>', 'as --timeout of run: for tasks without their own, such as 30m'))
160
172
  .addOption(new commander_1.Option('--max-timeout <duration>', 'as --max-timeout of run: for every task, also one with its own timeout'))
173
+ .addOption(new commander_1.Option('--detach', 'submit the run and exit, without following it: for a check that CI verifies later'))
161
174
  .action(guarded('Remote run was not successful', async (tasks, options) => {
162
175
  const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
163
176
  const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
@@ -194,6 +207,10 @@ function registerWorkCommands(remote, context) {
194
207
  }
195
208
  const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
196
209
  environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
210
+ if (options.detach) {
211
+ environment.stdout.write(`follow it with: hammerkit remote show ${(0, server_client_1.printable)(submitted.id)}\n`);
212
+ return;
213
+ }
197
214
  const prefixed = tasks.length > 1;
198
215
  const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
199
216
  signal: environment.abortCtrl.signal,
@@ -297,6 +314,57 @@ function registerWorkCommands(remote, context) {
297
314
  const run = (await (0, server_client_1.callServer)(server, token, 'POST', `/v1/runs/${id}/cancel`)).body;
298
315
  environment.stdout.write(`${(0, server_client_1.printable)(run.id)} on ${serverName}: ${run.state === 'up' || run.state === 'running' ? 'stopping' : (0, server_client_1.printable)(run.state)}\n`);
299
316
  }));
317
+ remote
318
+ .command('verify <tasks...>')
319
+ .description('check that the server already passed tasks for the files checked out here, without running them; ' +
320
+ 'exits 1 at once for any it did not')
321
+ .addOption(new commander_1.Option('--server <name>', 'the server to ask; the default of remote use when left out'))
322
+ .addOption(new commander_1.Option('--repo <host/path>', 'the repository, such as github.com/corp/payments; origin of the checkout here when left out'))
323
+ .addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
324
+ .action(guarded('Remote verify was not successful', async (tasks, options) => {
325
+ const repo = options.repo ?? (await (0, annotations_1.readCheckout)((0, path_1.dirname)(buildFile))).repo;
326
+ if (!repo) {
327
+ throw new Error('name the repository with --repo, the checkout here has no origin it can be told by');
328
+ }
329
+ const keys = [];
330
+ const unkeyed = [];
331
+ for (const task of tasks) {
332
+ const found = (await (0, check_keys_1.getCheckKeys)(buildFile, environment, { taskName: task, environmentName: options.env ?? null }))[task];
333
+ if (!found) {
334
+ throw new Error(`the build file has no task ${(0, server_client_1.printable)(task)}`);
335
+ }
336
+ if (found.key === null) {
337
+ unkeyed.push(`${(0, server_client_1.printable)(task)}: can not be verified, ${(0, server_client_1.printable)(found.reason)}`);
338
+ }
339
+ else {
340
+ keys.push({ task, key: found.key });
341
+ }
342
+ }
343
+ const { name: serverName, server, token } = await connect(options.server);
344
+ const answer = keys.length > 0
345
+ ? await (0, server_client_1.callServer)(server, token, 'POST', '/v1/checks/lookup', { repo, keys })
346
+ : { body: { checks: [] } };
347
+ const missing = [];
348
+ // by what was asked, never by what was answered: a task the answer leaves out, or
349
+ // answers for another key, is not verified
350
+ for (const { task, key } of keys) {
351
+ const check = answer.body.checks.find((answered) => answered.task === task && answered.key === key);
352
+ if (check?.passed) {
353
+ environment.stdout.write(`${(0, server_client_1.printable)(task)}: ${(0, server_client_1.printable)(describePassed(check.passed))}\n`);
354
+ }
355
+ else {
356
+ missing.push(task);
357
+ environment.stdout.write(`${(0, server_client_1.printable)(task)}: no run on ${serverName} passed it for these files${check?.reason ? `, ${(0, server_client_1.printable)(check.reason)}` : ''}\n`);
358
+ }
359
+ }
360
+ for (const line of unkeyed) {
361
+ environment.stdout.write(`${line}\n`);
362
+ }
363
+ if (missing.length > 0 || unkeyed.length > 0) {
364
+ const run = missing.length > 0 ? `; run on the server: hammerkit remote run ${missing.map(server_client_1.printable).join(' ')}` : '';
365
+ fail(`not verified: ${[...missing, ...unkeyed.map((line) => line.split(':')[0])].join(', ')}${run}`, 1);
366
+ }
367
+ }));
300
368
  remote
301
369
  .command('versions [server]')
302
370
  .description('list the published versions of build files on a server, newest first, with where they come from')
@@ -31,6 +31,7 @@ async function handleAccounts(config, log, principal, req, res, url) {
31
31
  accounts,
32
32
  repos: grant.repos,
33
33
  builds: grant.builds,
34
+ checks: grant.checks || grant.builds !== null ? 'read' : null,
34
35
  sandboxes: grant.sandboxes,
35
36
  endpoints: grant.endpoints,
36
37
  });