@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.
- package/dist/src/core/event-builders.js +139 -0
- package/dist/src/core/event-resolver.js +249 -0
- package/dist/src/core/outbox.js +123 -0
- package/dist/src/core/policy-engine.js +57 -3
- package/dist/src/core/projection-updater.js +80 -26
- package/dist/src/core/stale-run-reconciler.js +102 -0
- package/dist/src/core/tick-runner.js +38 -716
- package/dist/src/core/workspace-cleanup.js +77 -0
- package/dist/src/domain/schema.js +30 -0
- package/dist/src/domain/workflows.js +69 -0
- package/dist/src/lib/format.js +41 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const wakeVersion = "0.1.
|
|
1
|
+
export const wakeVersion = "0.1.13+g386bfbe";
|