@kal-elsam/kairo-runtime 0.14.0 → 0.15.0
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/CHANGELOG.md +84 -0
- package/bin/kairo-runtime.js +0 -0
- package/bin/kairo.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +172 -8
- package/src/global/check-resolutions.js +31 -0
- package/src/global/cli-help.js +19 -2
- package/src/global/component-ecosystem-checks.js +2 -0
- package/src/global/component-integration-cli.js +29 -10
- package/src/global/components-resolve-cli.js +246 -0
- package/src/global/connection-actions.js +147 -0
- package/src/global/connections.js +269 -0
- package/src/global/fleet-configure-plan.js +123 -0
- package/src/global/fleet-configure.js +303 -0
- package/src/global/fleet-models.js +188 -0
- package/src/global/fleet-set.js +219 -0
- package/src/global/fleet-shared.js +38 -0
- package/src/global/ink/cockpit-controller.js +1 -1
- package/src/global/ink/cockpit-models.js +4 -1
- package/src/global/ink/orchestrator-app.js +21 -2
- package/src/global/ink/ux/live-overview.js +5 -11
- package/src/global/ink/ux/overview-needs.js +1 -1
- package/src/global/integrations/engram-evidence.js +7 -2
- package/src/global/integrations/sdd-apply.js +17 -7
- package/src/global/integrations/sdd-evidence.js +22 -3
- package/src/global/integrations/sdd-plan.js +21 -3
- package/src/global/integrations/sdd-resolutions.js +73 -0
- package/src/global/integrations/sdd-state.js +69 -0
- package/src/global/integrations/sdd-verify.js +9 -4
- package/src/global/mcp/kairo-mcp.js +56 -5
- package/src/global/mcp/resolve-mcp-workspace.js +51 -0
- package/src/global/mcp/work-snapshot-rule.js +89 -0
- package/src/global/mcp/work-snapshot-tool.js +49 -0
- package/src/global/mcp-install.js +239 -0
- package/src/global/next/next-cli.js +35 -0
- package/src/global/next/next-report.js +145 -0
- package/src/global/next/project-key.js +36 -0
- package/src/global/next/publish-work-snapshot.js +116 -0
- package/src/global/next/work-enroll.js +91 -0
- package/src/global/next/work-snapshot.js +216 -0
- package/src/global/observability/fleet-activity.js +197 -0
- package/src/global/observability/fleet-models-catalog.js +137 -0
- package/src/global/observability/fleet-platforms.js +166 -0
- package/src/global/observability/fleet-probe.js +229 -0
- package/src/global/paths.js +2 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolveHomeDir } from "../paths.js";
|
|
2
|
+
import { printJson } from "../json-output.js";
|
|
3
|
+
import { commandHeader } from "../brand/index.js";
|
|
4
|
+
import { buildNextReport } from "./next-report.js";
|
|
5
|
+
|
|
6
|
+
export async function runNextCli(options = {}) {
|
|
7
|
+
const report = await buildNextReport({
|
|
8
|
+
homeDir: options.homeDir ?? resolveHomeDir(),
|
|
9
|
+
cwd: options.cwd ?? process.cwd(),
|
|
10
|
+
provider: options.provider ?? "cursor",
|
|
11
|
+
client: options.mcpClient ?? options.client ?? "cursor"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (options.json) {
|
|
15
|
+
printJson(report);
|
|
16
|
+
return report;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log(commandHeader("Next"));
|
|
20
|
+
console.log(`Integration · ${report.integration.state}`);
|
|
21
|
+
if (report.goal) console.log(`Goal · ${report.goal}`);
|
|
22
|
+
if (report.now) console.log(`Now · ${report.now}`);
|
|
23
|
+
if (report.next) console.log(`Next · ${report.next}`);
|
|
24
|
+
if (report.blockers?.length) {
|
|
25
|
+
console.log("Blockers");
|
|
26
|
+
for (const item of report.blockers) console.log(`- ${item}`);
|
|
27
|
+
}
|
|
28
|
+
if (!report.goal && !report.now && !report.next) {
|
|
29
|
+
console.log("No published work snapshot for this workspace.");
|
|
30
|
+
}
|
|
31
|
+
if (report.integration.showRepair) {
|
|
32
|
+
console.log("Repair · MCP configuration looks broken. Re-run: kairo mcp install --yes");
|
|
33
|
+
}
|
|
34
|
+
return report;
|
|
35
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kairo.next/v1 — selected work snapshot + honest integration state for the panel.
|
|
3
|
+
* Never invents Goal/Progress/Now/Blockers/Next when data is absent or corrupt.
|
|
4
|
+
*/
|
|
5
|
+
import { detectAgentMcpRegistration } from "../connections.js";
|
|
6
|
+
import { resolveHomeDir } from "../paths.js";
|
|
7
|
+
import { loadEnrollment } from "./work-enroll.js";
|
|
8
|
+
import {
|
|
9
|
+
listWorkSnapshots,
|
|
10
|
+
snapshotIsComplete
|
|
11
|
+
} from "./work-snapshot.js";
|
|
12
|
+
|
|
13
|
+
export const NEXT_SCHEMA = "kairo.next/v1";
|
|
14
|
+
|
|
15
|
+
export const INTEGRATION_STATE = Object.freeze({
|
|
16
|
+
MISSING: "missing",
|
|
17
|
+
READY: "ready",
|
|
18
|
+
ACTIVE: "active",
|
|
19
|
+
BROKEN: "broken"
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function teamFromDelegations(delegations) {
|
|
23
|
+
if (!Array.isArray(delegations) || delegations.length === 0) return undefined;
|
|
24
|
+
const members = delegations
|
|
25
|
+
.map((row) => {
|
|
26
|
+
if (!row || typeof row !== "object") return null;
|
|
27
|
+
const title = typeof row.title === "string" ? row.title.slice(0, 160) : null;
|
|
28
|
+
const workId = typeof row.workId === "string" ? row.workId : null;
|
|
29
|
+
if (!title && !workId) return null;
|
|
30
|
+
return {
|
|
31
|
+
...(workId ? { workId } : {}),
|
|
32
|
+
...(title ? { title } : {}),
|
|
33
|
+
...(row.role ? { role: row.role } : {}),
|
|
34
|
+
...(row.state ? { state: row.state } : {})
|
|
35
|
+
};
|
|
36
|
+
})
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
return members.length > 0 ? { members } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveIntegrationState({ mcp, hasUsableSnapshot }) {
|
|
42
|
+
if (mcp?.state === "error") {
|
|
43
|
+
return {
|
|
44
|
+
state: INTEGRATION_STATE.BROKEN,
|
|
45
|
+
mcpConnected: false,
|
|
46
|
+
showRepair: true,
|
|
47
|
+
detail: mcp.detail ?? "MCP configuration could not be read."
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (!mcp?.connected) {
|
|
51
|
+
return {
|
|
52
|
+
state: INTEGRATION_STATE.MISSING,
|
|
53
|
+
mcpConnected: false,
|
|
54
|
+
showRepair: false,
|
|
55
|
+
detail: mcp?.detail ?? "Kairo MCP is not registered."
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (hasUsableSnapshot) {
|
|
59
|
+
return {
|
|
60
|
+
state: INTEGRATION_STATE.ACTIVE,
|
|
61
|
+
mcpConnected: true,
|
|
62
|
+
showRepair: false,
|
|
63
|
+
detail: "MCP connected with a usable work snapshot."
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
state: INTEGRATION_STATE.READY,
|
|
68
|
+
mcpConnected: true,
|
|
69
|
+
showRepair: false,
|
|
70
|
+
detail: "MCP connected; waiting for a published work snapshot."
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function viewFromSnapshot(snapshot) {
|
|
75
|
+
if (!snapshot) {
|
|
76
|
+
return {
|
|
77
|
+
goal: null,
|
|
78
|
+
progress: [],
|
|
79
|
+
now: null,
|
|
80
|
+
blockers: [],
|
|
81
|
+
next: null,
|
|
82
|
+
conversationId: null,
|
|
83
|
+
updatedAt: null
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const team = teamFromDelegations(snapshot.delegations);
|
|
87
|
+
return {
|
|
88
|
+
goal: snapshot.goal ?? null,
|
|
89
|
+
progress: Array.isArray(snapshot.progress) ? snapshot.progress : [],
|
|
90
|
+
now: snapshot.now ?? null,
|
|
91
|
+
blockers: Array.isArray(snapshot.blockers) ? snapshot.blockers : [],
|
|
92
|
+
next: snapshot.next ?? null,
|
|
93
|
+
conversationId: snapshot.conversationId ?? null,
|
|
94
|
+
updatedAt: snapshot.updatedAt ?? null,
|
|
95
|
+
...(team ? { team } : {})
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the next report for the runtime workspace (deps.cwd / process.cwd()).
|
|
101
|
+
*/
|
|
102
|
+
export async function buildNextReport({
|
|
103
|
+
homeDir = resolveHomeDir(),
|
|
104
|
+
cwd = process.cwd(),
|
|
105
|
+
provider = "cursor",
|
|
106
|
+
client = "cursor",
|
|
107
|
+
detectAgent = detectAgentMcpRegistration,
|
|
108
|
+
listSnapshots = listWorkSnapshots,
|
|
109
|
+
loadEnrollmentFn = loadEnrollment
|
|
110
|
+
} = {}) {
|
|
111
|
+
const mcp = await detectAgent({ client, homeDir });
|
|
112
|
+
const listed = await listSnapshots(homeDir, cwd);
|
|
113
|
+
// Prefer newest complete snapshot — incomplete records must not hide valid work.
|
|
114
|
+
const snapshot = listed.find((row) => snapshotIsComplete(row)) ?? null;
|
|
115
|
+
const complete = snapshotIsComplete(snapshot);
|
|
116
|
+
const integrationCore = resolveIntegrationState({
|
|
117
|
+
mcp,
|
|
118
|
+
hasUsableSnapshot: complete
|
|
119
|
+
});
|
|
120
|
+
const view = viewFromSnapshot(snapshot);
|
|
121
|
+
|
|
122
|
+
let enrolled = false;
|
|
123
|
+
if (view.conversationId) {
|
|
124
|
+
const enrollment = await loadEnrollmentFn(homeDir, cwd, view.conversationId);
|
|
125
|
+
enrolled = Boolean(enrollment);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
schema: NEXT_SCHEMA,
|
|
130
|
+
ok: integrationCore.state !== INTEGRATION_STATE.BROKEN,
|
|
131
|
+
...view,
|
|
132
|
+
integration: {
|
|
133
|
+
state: integrationCore.state,
|
|
134
|
+
provider,
|
|
135
|
+
client,
|
|
136
|
+
mcpConnected: integrationCore.mcpConnected,
|
|
137
|
+
enrolled,
|
|
138
|
+
showRepair: integrationCore.showRepair === true,
|
|
139
|
+
detail: integrationCore.detail
|
|
140
|
+
},
|
|
141
|
+
diagnostics: integrationCore.state === INTEGRATION_STATE.BROKEN
|
|
142
|
+
? ["integration_broken"]
|
|
143
|
+
: []
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Canonical absolute path for workspace identity.
|
|
7
|
+
* On macOS `/tmp` → `/private/tmp`; without realpath those alias to different keys.
|
|
8
|
+
* Walks up to an existing ancestor when the leaf path does not exist yet.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalizeProjectPath(projectPath) {
|
|
11
|
+
const resolved = resolve(String(projectPath ?? ""));
|
|
12
|
+
try {
|
|
13
|
+
return realpathSync(resolved);
|
|
14
|
+
} catch {
|
|
15
|
+
const parts = resolved.split(sep);
|
|
16
|
+
for (let i = parts.length - 1; i > 0; i -= 1) {
|
|
17
|
+
const prefix = parts.slice(0, i).join(sep) || sep;
|
|
18
|
+
try {
|
|
19
|
+
const realPrefix = realpathSync(prefix);
|
|
20
|
+
return resolve(realPrefix, ...parts.slice(i));
|
|
21
|
+
} catch {
|
|
22
|
+
// keep walking up
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stable workspace key derived from an absolute project path.
|
|
31
|
+
* Agents must not supply this — callers compute it from runtime cwd/workspace.
|
|
32
|
+
*/
|
|
33
|
+
export function projectKeyForPath(projectPath) {
|
|
34
|
+
const normalized = canonicalizeProjectPath(projectPath).toLowerCase();
|
|
35
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
36
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish kairo.work-snapshot/v1 from runtime-derived workspace identity.
|
|
3
|
+
*/
|
|
4
|
+
import { resolveHomeDir } from "../paths.js";
|
|
5
|
+
import { projectKeyForPath } from "./project-key.js";
|
|
6
|
+
import { enrollConversation } from "./work-enroll.js";
|
|
7
|
+
import {
|
|
8
|
+
assertNoWorkPrivatePayload,
|
|
9
|
+
createWorkSnapshot,
|
|
10
|
+
isIgnoredSmokeConversationId,
|
|
11
|
+
saveWorkSnapshot,
|
|
12
|
+
selectLatestWorkSnapshot,
|
|
13
|
+
snapshotIsComplete
|
|
14
|
+
} from "./work-snapshot.js";
|
|
15
|
+
|
|
16
|
+
const FORBIDDEN = Object.freeze([
|
|
17
|
+
"projectKey", "projectPath", "cwd", "homeDir", "workspaceRoot"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function fail(code) {
|
|
21
|
+
return { ok: false, code, data: null, diagnostics: [code] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate → enroll → atomic snapshot write.
|
|
26
|
+
* Workspace comes only from deps.cwd / process.cwd().
|
|
27
|
+
*/
|
|
28
|
+
export async function publishWorkSnapshot(input = {}, deps = {}) {
|
|
29
|
+
try {
|
|
30
|
+
assertNoWorkPrivatePayload(input);
|
|
31
|
+
} catch {
|
|
32
|
+
return fail("private_payload");
|
|
33
|
+
}
|
|
34
|
+
for (const key of FORBIDDEN) {
|
|
35
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
36
|
+
return fail("forbidden_identity_fields");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const conversationId = typeof input.conversationId === "string"
|
|
41
|
+
? input.conversationId.trim()
|
|
42
|
+
: "";
|
|
43
|
+
if (!conversationId) return fail("conversation_required");
|
|
44
|
+
if (isIgnoredSmokeConversationId(conversationId)) return fail("ignored_conversation");
|
|
45
|
+
|
|
46
|
+
const provider = typeof input.provider === "string" && input.provider.trim()
|
|
47
|
+
? input.provider.trim().slice(0, 40)
|
|
48
|
+
: null;
|
|
49
|
+
if (!provider) return fail("provider_required");
|
|
50
|
+
|
|
51
|
+
const draft = createWorkSnapshot({
|
|
52
|
+
goal: input.goal,
|
|
53
|
+
progress: input.progress,
|
|
54
|
+
now: input.now,
|
|
55
|
+
blockers: input.blockers,
|
|
56
|
+
next: input.next,
|
|
57
|
+
delegations: input.delegations,
|
|
58
|
+
conversationId,
|
|
59
|
+
provider
|
|
60
|
+
});
|
|
61
|
+
if (!snapshotIsComplete(draft)) return fail("incomplete_snapshot");
|
|
62
|
+
|
|
63
|
+
const homeDir = deps.homeDir ?? resolveHomeDir();
|
|
64
|
+
const projectPath = deps.cwd ?? process.cwd();
|
|
65
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
66
|
+
const io = { now: deps.now, writeAtomic: deps.writeAtomic };
|
|
67
|
+
|
|
68
|
+
let enrollmentResult;
|
|
69
|
+
try {
|
|
70
|
+
enrollmentResult = await (deps.enrollConversation ?? enrollConversation)(
|
|
71
|
+
homeDir, projectPath, { conversationId, provider }, io
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
return fail("enroll_failed");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let saved;
|
|
78
|
+
try {
|
|
79
|
+
saved = await (deps.saveWorkSnapshot ?? saveWorkSnapshot)(
|
|
80
|
+
homeDir, projectPath, conversationId, draft, io
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
return fail("snapshot_write_failed");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (saved.projectKey !== projectKey || saved.conversationId !== conversationId) {
|
|
87
|
+
return fail("identity_mismatch");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const latest = await (deps.selectLatestWorkSnapshot ?? selectLatestWorkSnapshot)(
|
|
91
|
+
homeDir, projectPath
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
code: enrollmentResult.created ? "enrolled" : "updated",
|
|
97
|
+
data: {
|
|
98
|
+
projectKey,
|
|
99
|
+
conversationId,
|
|
100
|
+
enrolled: true,
|
|
101
|
+
created: enrollmentResult.created === true,
|
|
102
|
+
snapshot: {
|
|
103
|
+
schema: saved.schema,
|
|
104
|
+
goal: saved.goal,
|
|
105
|
+
progress: saved.progress,
|
|
106
|
+
now: saved.now,
|
|
107
|
+
blockers: saved.blockers,
|
|
108
|
+
next: saved.next,
|
|
109
|
+
...(saved.delegations ? { delegations: saved.delegations } : {}),
|
|
110
|
+
updatedAt: saved.updatedAt
|
|
111
|
+
},
|
|
112
|
+
selected: latest?.conversationId === conversationId
|
|
113
|
+
},
|
|
114
|
+
diagnostics: []
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent conversation enrollment scoped by runtime projectKey + conversationId.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { harnessHomePaths } from "../paths.js";
|
|
7
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
8
|
+
import { projectKeyForPath } from "./project-key.js";
|
|
9
|
+
import { snapshotFileId } from "./work-snapshot.js";
|
|
10
|
+
|
|
11
|
+
export const WORK_ENROLLMENT_SCHEMA = "kairo.work-enrollment/v1";
|
|
12
|
+
|
|
13
|
+
function enrollmentPath(homeDir, projectKey, conversationId) {
|
|
14
|
+
return join(
|
|
15
|
+
harnessHomePaths(homeDir).sessionsDir,
|
|
16
|
+
projectKey,
|
|
17
|
+
"enrollments",
|
|
18
|
+
`${snapshotFileId(conversationId)}.json`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Never trusts agent-supplied projectKey — derives it from projectPath. */
|
|
23
|
+
export async function enrollConversation(
|
|
24
|
+
homeDir,
|
|
25
|
+
projectPath,
|
|
26
|
+
{ conversationId, provider = null } = {},
|
|
27
|
+
deps = {}
|
|
28
|
+
) {
|
|
29
|
+
if (typeof conversationId !== "string" || !conversationId.trim()) {
|
|
30
|
+
throw new Error("conversationId is required to enroll.");
|
|
31
|
+
}
|
|
32
|
+
const id = conversationId.trim().slice(0, 160);
|
|
33
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
34
|
+
const path = enrollmentPath(homeDir, projectKey, id);
|
|
35
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
36
|
+
const nowIso = deps.now ? deps.now() : new Date().toISOString();
|
|
37
|
+
const writeAtomic = deps.writeAtomic ?? writeAtomicJson;
|
|
38
|
+
|
|
39
|
+
let existing = null;
|
|
40
|
+
try {
|
|
41
|
+
existing = JSON.parse(await readFile(path, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
existing = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (existing) {
|
|
47
|
+
if (
|
|
48
|
+
existing.schema !== WORK_ENROLLMENT_SCHEMA
|
|
49
|
+
|| existing.projectKey !== projectKey
|
|
50
|
+
|| existing.conversationId !== id
|
|
51
|
+
) {
|
|
52
|
+
throw new Error("enrollment_identity_mismatch");
|
|
53
|
+
}
|
|
54
|
+
const refreshed = {
|
|
55
|
+
...existing,
|
|
56
|
+
provider: provider ? String(provider).slice(0, 40) : existing.provider,
|
|
57
|
+
updatedAt: nowIso
|
|
58
|
+
};
|
|
59
|
+
await writeAtomic(path, refreshed);
|
|
60
|
+
return { created: false, enrollment: refreshed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const enrollment = {
|
|
64
|
+
schema: WORK_ENROLLMENT_SCHEMA,
|
|
65
|
+
projectKey,
|
|
66
|
+
conversationId: id,
|
|
67
|
+
provider: provider ? String(provider).slice(0, 40) : null,
|
|
68
|
+
enrolledAt: nowIso,
|
|
69
|
+
updatedAt: nowIso
|
|
70
|
+
};
|
|
71
|
+
await writeAtomic(path, enrollment);
|
|
72
|
+
return { created: true, enrollment };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadEnrollment(homeDir, projectPath, conversationId) {
|
|
76
|
+
if (typeof conversationId !== "string" || !conversationId.trim()) return null;
|
|
77
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(
|
|
80
|
+
await readFile(enrollmentPath(homeDir, projectKey, conversationId.trim()), "utf8")
|
|
81
|
+
);
|
|
82
|
+
if (
|
|
83
|
+
raw?.schema !== WORK_ENROLLMENT_SCHEMA
|
|
84
|
+
|| raw.projectKey !== projectKey
|
|
85
|
+
|| raw.conversationId !== conversationId.trim()
|
|
86
|
+
) return null;
|
|
87
|
+
return raw;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kairo.work-snapshot/v1 — semantic work state for observability.
|
|
3
|
+
* Never stores prompts, transcripts, or agent-supplied workspace identity.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { harnessHomePaths } from "../paths.js";
|
|
9
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
10
|
+
import { projectKeyForPath } from "./project-key.js";
|
|
11
|
+
|
|
12
|
+
export const WORK_SNAPSHOT_SCHEMA = "kairo.work-snapshot/v1";
|
|
13
|
+
|
|
14
|
+
/** Known smoke/fixture conversation ids — ignored on read, never auto-deleted. */
|
|
15
|
+
export const IGNORED_SMOKE_CONVERSATION_IDS = Object.freeze([
|
|
16
|
+
"echopilot-visual-smoke"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const PRIVATE_KEYS = Object.freeze([
|
|
20
|
+
"prompt", "prompts", "transcript", "transcripts", "response", "messages"
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const TECH_LEAK_RE =
|
|
24
|
+
/\bks_[a-f0-9]{8,}\b|kairo\.(?:work|next|session)\b|call kairo_|engramRef|schema\s*[:=]/i;
|
|
25
|
+
|
|
26
|
+
export function isIgnoredSmokeConversationId(id) {
|
|
27
|
+
return Boolean(id) && IGNORED_SMOKE_CONVERSATION_IDS.includes(String(id));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function looksLikeTechnicalLeak(text) {
|
|
31
|
+
return TECH_LEAK_RE.test(String(text ?? ""));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertNoWorkPrivatePayload(input) {
|
|
35
|
+
if (!input || typeof input !== "object") return;
|
|
36
|
+
for (const key of PRIVATE_KEYS) {
|
|
37
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
38
|
+
throw new Error(`Private field "${key}" is not allowed on work payloads.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function snapshotsDir(homeDir, projectKey) {
|
|
44
|
+
return join(harnessHomePaths(homeDir).sessionsDir, projectKey, "snapshots");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stable file id — avoids collisions from sanitized path characters. */
|
|
48
|
+
export function snapshotFileId(conversationId) {
|
|
49
|
+
return createHash("sha256").update(String(conversationId)).digest("hex").slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function snapshotFilePath(homeDir, projectKey, conversationId) {
|
|
53
|
+
return join(snapshotsDir(homeDir, projectKey), `${snapshotFileId(conversationId)}.json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveConversationId(conversationId, snapshot) {
|
|
57
|
+
const raw = conversationId ?? snapshot?.conversationId ?? null;
|
|
58
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
59
|
+
throw new Error("conversationId is required to save a work snapshot.");
|
|
60
|
+
}
|
|
61
|
+
return raw.trim().slice(0, 160);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cleanText(value, max) {
|
|
65
|
+
if (typeof value !== "string") return null;
|
|
66
|
+
const trimmed = value.trim();
|
|
67
|
+
if (!trimmed || looksLikeTechnicalLeak(trimmed)) return null;
|
|
68
|
+
return trimmed.slice(0, max);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cleanTextList(list, maxItem, maxItems) {
|
|
72
|
+
if (!Array.isArray(list)) return [];
|
|
73
|
+
return list.map((item) => cleanText(item, maxItem)).filter(Boolean).slice(0, maxItems);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sanitizeDelegations(list) {
|
|
77
|
+
if (!Array.isArray(list)) return [];
|
|
78
|
+
return list
|
|
79
|
+
.map((row) => {
|
|
80
|
+
if (!row || typeof row !== "object") return null;
|
|
81
|
+
const title = cleanText(row.title ?? row.goal, 160);
|
|
82
|
+
const workId = typeof row.workId === "string" && /^kw_[a-f0-9]{32}$/i.test(row.workId)
|
|
83
|
+
? row.workId
|
|
84
|
+
: null;
|
|
85
|
+
if (!title && !workId) return null;
|
|
86
|
+
const role = row.role === "orchestrator" || row.role === "worker" ? row.role : null;
|
|
87
|
+
const state = ["assigned", "working", "blocked", "completed", "failed"].includes(row.state)
|
|
88
|
+
? row.state
|
|
89
|
+
: null;
|
|
90
|
+
return {
|
|
91
|
+
...(workId ? { workId } : {}),
|
|
92
|
+
...(title ? { title } : {}),
|
|
93
|
+
...(role ? { role } : {}),
|
|
94
|
+
...(state ? { state } : {})
|
|
95
|
+
};
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.slice(0, 12);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isWorkSnapshotSchema(schema) {
|
|
102
|
+
return schema === WORK_SNAPSHOT_SCHEMA;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a sanitized v1 snapshot. Incomplete inputs keep nulls — never invent text. */
|
|
106
|
+
export function createWorkSnapshot(input = {}) {
|
|
107
|
+
assertNoWorkPrivatePayload(input);
|
|
108
|
+
const goal = cleanText(input.goal, 160);
|
|
109
|
+
const now = cleanText(input.now, 240);
|
|
110
|
+
const next = cleanText(input.next, 240);
|
|
111
|
+
const progress = cleanTextList(input.progress, 160, 3);
|
|
112
|
+
const blockers = cleanTextList(input.blockers, 200, 12);
|
|
113
|
+
const delegations = sanitizeDelegations(input.delegations);
|
|
114
|
+
const conversationId = input.conversationId
|
|
115
|
+
? String(input.conversationId).slice(0, 160)
|
|
116
|
+
: null;
|
|
117
|
+
const provider = input.provider ? String(input.provider).slice(0, 40) : null;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
schema: WORK_SNAPSHOT_SCHEMA,
|
|
121
|
+
goal,
|
|
122
|
+
progress,
|
|
123
|
+
now,
|
|
124
|
+
blockers,
|
|
125
|
+
next,
|
|
126
|
+
...(delegations.length > 0 ? { delegations } : {}),
|
|
127
|
+
conversationId,
|
|
128
|
+
provider,
|
|
129
|
+
updatedAt: input.updatedAt ?? new Date().toISOString()
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function snapshotIsComplete(snapshot) {
|
|
134
|
+
return Boolean(
|
|
135
|
+
snapshot
|
|
136
|
+
&& isWorkSnapshotSchema(snapshot.schema)
|
|
137
|
+
&& snapshot.goal
|
|
138
|
+
&& snapshot.now
|
|
139
|
+
&& snapshot.next
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function acceptStoredSnapshot(raw) {
|
|
144
|
+
if (!isWorkSnapshotSchema(raw?.schema)) return null;
|
|
145
|
+
if (isIgnoredSmokeConversationId(raw.conversationId)) return null;
|
|
146
|
+
return raw;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function saveWorkSnapshot(
|
|
150
|
+
homeDir,
|
|
151
|
+
projectPath,
|
|
152
|
+
conversationId,
|
|
153
|
+
snapshot,
|
|
154
|
+
deps = {}
|
|
155
|
+
) {
|
|
156
|
+
const resolvedId = resolveConversationId(conversationId, snapshot);
|
|
157
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
158
|
+
await mkdir(snapshotsDir(homeDir, projectKey), { recursive: true });
|
|
159
|
+
const nowIso = deps.now ? deps.now() : new Date().toISOString();
|
|
160
|
+
const payload = {
|
|
161
|
+
...createWorkSnapshot({
|
|
162
|
+
...snapshot,
|
|
163
|
+
conversationId: resolvedId,
|
|
164
|
+
updatedAt: nowIso
|
|
165
|
+
}),
|
|
166
|
+
projectKey,
|
|
167
|
+
updatedAt: nowIso
|
|
168
|
+
};
|
|
169
|
+
await (deps.writeAtomic ?? writeAtomicJson)(
|
|
170
|
+
snapshotFilePath(homeDir, projectKey, resolvedId),
|
|
171
|
+
payload
|
|
172
|
+
);
|
|
173
|
+
return payload;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function loadWorkSnapshot(homeDir, projectPath, conversationId) {
|
|
177
|
+
if (typeof conversationId !== "string" || !conversationId.trim()) return null;
|
|
178
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
179
|
+
try {
|
|
180
|
+
const raw = JSON.parse(
|
|
181
|
+
await readFile(snapshotFilePath(homeDir, projectKey, conversationId.trim()), "utf8")
|
|
182
|
+
);
|
|
183
|
+
return acceptStoredSnapshot(raw);
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function listWorkSnapshots(homeDir, projectPath) {
|
|
190
|
+
const projectKey = projectKeyForPath(projectPath);
|
|
191
|
+
const dir = snapshotsDir(homeDir, projectKey);
|
|
192
|
+
let names = [];
|
|
193
|
+
try {
|
|
194
|
+
names = await readdir(dir);
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const name of names) {
|
|
200
|
+
if (!name.endsWith(".json")) continue;
|
|
201
|
+
try {
|
|
202
|
+
const raw = JSON.parse(await readFile(join(dir, name), "utf8"));
|
|
203
|
+
const accepted = acceptStoredSnapshot(raw);
|
|
204
|
+
if (accepted) out.push(accepted);
|
|
205
|
+
} catch {
|
|
206
|
+
// corrupt files are skipped — callers see absence, not invented content
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Most recently updated real snapshot for this workspace. */
|
|
213
|
+
export async function selectLatestWorkSnapshot(homeDir, projectPath) {
|
|
214
|
+
const [latest] = await listWorkSnapshots(homeDir, projectPath);
|
|
215
|
+
return latest ?? null;
|
|
216
|
+
}
|