@atolis-hq/wake 0.1.11 → 0.1.13

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.
@@ -0,0 +1,77 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ import { createEventEnvelope } from '../lib/event-log.js';
4
+ // Cleans up per-issue workspaces (and, unless retained, transcripts) once the
5
+ // originating issue is closed. A cleanup failure is recorded as an event and
6
+ // skipped rather than aborting the sweep.
7
+ export function createWorkspaceCleanup(deps) {
8
+ function eventStampNow() {
9
+ return deps.clock.now().toISOString();
10
+ }
11
+ function isPerIssueWorkspacePath(workspacePath) {
12
+ const workspacesRoot = join(deps.config.paths.wakeRoot, 'workspaces');
13
+ const rel = relative(workspacesRoot, workspacePath);
14
+ return !rel.startsWith('..') && !isAbsolute(rel) && rel.length > 0;
15
+ }
16
+ async function cleanupClosedIssueWorkspaces(projections) {
17
+ for (const projection of projections) {
18
+ const { workspacePath } = projection.wake;
19
+ if (projection.issue.state === 'closed' &&
20
+ workspacePath !== undefined &&
21
+ isPerIssueWorkspacePath(workspacePath)) {
22
+ try {
23
+ await deps.workspaceManager.cleanupWorkspace({ workspacePath });
24
+ if (!deps.config.transcripts.retainAfterWorkspaceCleanup) {
25
+ await rm(deps.stateStore.paths.transcriptWorkDir(projection.workItemKey), {
26
+ recursive: true,
27
+ force: true,
28
+ });
29
+ }
30
+ }
31
+ catch (error) {
32
+ const failedAt = eventStampNow();
33
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
34
+ eventId: `workspace-cleanup-failed-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}`,
35
+ workItemKey: projection.workItemKey,
36
+ streamScope: 'work-item',
37
+ direction: 'internal',
38
+ sourceSystem: 'wake',
39
+ sourceEventType: 'wake.workspace.cleanup-failed',
40
+ sourceRefs: {
41
+ repo: projection.issue.repo,
42
+ issueNumber: projection.issue.number,
43
+ },
44
+ occurredAt: failedAt,
45
+ ingestedAt: failedAt,
46
+ trigger: 'context-only',
47
+ payload: {
48
+ workspacePath,
49
+ error: error instanceof Error ? error.message : String(error),
50
+ },
51
+ }));
52
+ continue;
53
+ }
54
+ const cleanedAt = eventStampNow();
55
+ const cleanupEvent = createEventEnvelope({
56
+ eventId: `workspace-cleaned-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}-${deps.clock.now().getTime()}`,
57
+ workItemKey: projection.workItemKey,
58
+ streamScope: 'work-item',
59
+ direction: 'internal',
60
+ sourceSystem: 'wake',
61
+ sourceEventType: 'wake.workspace.cleaned',
62
+ sourceRefs: {
63
+ repo: projection.issue.repo,
64
+ issueNumber: projection.issue.number,
65
+ },
66
+ occurredAt: cleanedAt,
67
+ ingestedAt: cleanedAt,
68
+ trigger: 'immediate',
69
+ payload: { workspacePath },
70
+ });
71
+ await deps.stateStore.appendEventEnvelope(cleanupEvent);
72
+ await deps.projectionUpdater.rebuildFromEvents([cleanupEvent]);
73
+ }
74
+ }
75
+ }
76
+ return { cleanupClosedIssueWorkspaces };
77
+ }
@@ -316,6 +316,26 @@ const workflowDefinitionSchema = z.object({
316
316
  entryStage: identifierSchema.optional(),
317
317
  stages: z.record(identifierSchema, workflowStageSchema),
318
318
  });
319
+ const workflowSelectorMatchSchema = z
320
+ .object({
321
+ kind: z.string().min(1).optional(),
322
+ sourceEventType: z.string().min(1).optional(),
323
+ repo: z.string().min(1).optional(),
324
+ requiredLabels: z.array(z.string()).default([]),
325
+ ignoredLabels: z.array(z.string()).default([]),
326
+ requiredAssignees: z.array(z.string()).default([]),
327
+ requiredAuthors: z.array(z.string()).default([]),
328
+ })
329
+ .default({
330
+ requiredLabels: [],
331
+ ignoredLabels: [],
332
+ requiredAssignees: [],
333
+ requiredAuthors: [],
334
+ });
335
+ const workflowSelectorSchema = z.object({
336
+ workflow: identifierSchema,
337
+ match: workflowSelectorMatchSchema,
338
+ });
319
339
  function defaultPromptsRoot() {
320
340
  const sourceDir = dirname(fileURLToPath(import.meta.url));
321
341
  for (const candidate of [
@@ -515,6 +535,7 @@ export const wakeConfigSchema = z
515
535
  },
516
536
  },
517
537
  }),
538
+ workflowSelectors: z.array(workflowSelectorSchema).default([]),
518
539
  commands: z.record(identifierSchema, customCommandSchema).default({
519
540
  ask: {
520
541
  action: 'ask',
@@ -707,6 +728,15 @@ export const wakeConfigSchema = z
707
728
  });
708
729
  }
709
730
  }
731
+ for (const [index, selector] of config.workflowSelectors.entries()) {
732
+ if (config.workflows[selector.workflow] === undefined) {
733
+ ctx.addIssue({
734
+ code: z.ZodIssueCode.custom,
735
+ path: ['workflowSelectors', index, 'workflow'],
736
+ message: `Workflow selector targets unknown workflow "${selector.workflow}".`,
737
+ });
738
+ }
739
+ }
710
740
  for (const [commandName, command] of commandEntries) {
711
741
  if (reservedCommandNames.includes(commandName.toLowerCase())) {
712
742
  ctx.addIssue({
@@ -37,6 +37,75 @@ export function workflowNameForProjection(projection, config) {
37
37
  const context = projection.context;
38
38
  return typeof context.workflow === 'string' ? context.workflow : defaultWorkflowName(config);
39
39
  }
40
+ function stringArray(value) {
41
+ return Array.isArray(value)
42
+ ? value.filter((entry) => typeof entry === 'string')
43
+ : [];
44
+ }
45
+ function repoFromResourceUri(resourceUri) {
46
+ if (resourceUri === undefined) {
47
+ return undefined;
48
+ }
49
+ const [, , rest] = resourceUri.split(':', 3);
50
+ return rest?.split('#')[0];
51
+ }
52
+ export function workflowSelectorInputFromEvent(event) {
53
+ const resourceUri = event.sourceRefs.resourceUri ?? event.sourceRefs.parentResourceUri;
54
+ const kind = resourceUri?.split(':')[1];
55
+ const ticket = (event.payload.ticket ?? event.payload.issue);
56
+ const pr = event.payload.pr;
57
+ const repo = (typeof ticket?.repo === 'string' ? ticket.repo : undefined) ??
58
+ event.sourceRefs.repo ??
59
+ repoFromResourceUri(resourceUri);
60
+ return {
61
+ ...(kind === undefined ? {} : { kind }),
62
+ sourceEventType: event.sourceEventType,
63
+ ...(repo === undefined ? {} : { repo }),
64
+ labels: stringArray(ticket?.labels),
65
+ assignees: stringArray(ticket?.assignees),
66
+ ...(typeof pr?.author === 'string' ? { author: pr.author } : {}),
67
+ };
68
+ }
69
+ function labelsAndAssigneesMatch(input) {
70
+ const labels = new Set(input.labels);
71
+ const assignees = new Set(input.assignees);
72
+ if (input.requiredLabels.some((label) => !labels.has(label))) {
73
+ return false;
74
+ }
75
+ if (input.ignoredLabels.some((label) => labels.has(label))) {
76
+ return false;
77
+ }
78
+ if (input.requiredAssignees.length > 0 &&
79
+ !input.requiredAssignees.some((login) => assignees.has(login))) {
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+ function selectorMatches(selector, input) {
85
+ const match = selector.match;
86
+ if (match.kind !== undefined && match.kind !== input.kind) {
87
+ return false;
88
+ }
89
+ if (match.sourceEventType !== undefined && match.sourceEventType !== input.sourceEventType) {
90
+ return false;
91
+ }
92
+ if (match.repo !== undefined && match.repo !== input.repo) {
93
+ return false;
94
+ }
95
+ if (!labelsAndAssigneesMatch({ ...match, labels: input.labels, assignees: input.assignees })) {
96
+ return false;
97
+ }
98
+ if (match.requiredAuthors.length > 0 &&
99
+ (input.author === undefined || !match.requiredAuthors.includes(input.author))) {
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ export function selectWorkflowForEvent(event, config) {
105
+ const input = workflowSelectorInputFromEvent(event);
106
+ const selector = config.workflowSelectors.find((candidate) => selectorMatches(candidate, input));
107
+ return selector?.workflow ?? null;
108
+ }
40
109
  export function configuredStageNames(workflow) {
41
110
  return Object.keys(workflow.stages);
42
111
  }
@@ -0,0 +1,41 @@
1
+ // Presentation-only formatting for run summaries (cost, duration, tokens).
2
+ // Pure functions with no dependency on tick state — kept in lib/ so they are
3
+ // directly unit-testable rather than only reachable through a full run.
4
+ export function extractTokenCount(tokenUsage) {
5
+ if (tokenUsage === undefined) {
6
+ return undefined;
7
+ }
8
+ // Cache tokens dominate real usage and were previously dropped from this
9
+ // total entirely, understating the reported figure by roughly an order of
10
+ // magnitude (#135).
11
+ return (tokenUsage.inputTokens +
12
+ tokenUsage.outputTokens +
13
+ (tokenUsage.cacheCreationInputTokens ?? 0) +
14
+ (tokenUsage.cacheReadInputTokens ?? 0));
15
+ }
16
+ export function formatCostUsd(costUsd) {
17
+ return `$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`;
18
+ }
19
+ export function formatDuration(startedAtStr, finishedAtStr) {
20
+ const startedAt = new Date(startedAtStr);
21
+ const finishedAt = new Date(finishedAtStr);
22
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
23
+ if (durationMs < 0 || !isFinite(durationMs))
24
+ return undefined;
25
+ const totalSeconds = Math.floor(durationMs / 1000);
26
+ const minutes = Math.floor(totalSeconds / 60);
27
+ const seconds = totalSeconds % 60;
28
+ if (minutes > 0) {
29
+ return `${minutes}m${seconds}s`;
30
+ }
31
+ return `${seconds}s`;
32
+ }
33
+ export function formatTokenCount(count) {
34
+ if (count >= 1000000) {
35
+ return `${(count / 1000000).toFixed(1)}M`;
36
+ }
37
+ if (count >= 1000) {
38
+ return `${(count / 1000).toFixed(0)}k`;
39
+ }
40
+ return String(count);
41
+ }
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.11+gbbbf616";
1
+ export const wakeVersion = "0.1.13+g386bfbe";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {