pi-harness-runtime 1.1.411 → 1.1.413
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/harness/notification-settings.ts +130 -0
- package/harness/skill-remote-source.ts +157 -0
- package/harness/skill-sync.ts +74 -22
- package/harness/skills-startup.ts +229 -0
- package/harness/startup-report.ts +98 -56
- package/harness-startup-status.ts +31 -2
- package/index.ts +125 -31
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/dist/client.d.ts +10 -0
- package/packages/qdrant-skills/dist/client.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/client.js +41 -9
- package/packages/qdrant-skills/dist/client.js.map +1 -1
- package/packages/qdrant-skills/dist/embedder.d.ts +10 -0
- package/packages/qdrant-skills/dist/embedder.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/embedder.js +12 -2
- package/packages/qdrant-skills/dist/embedder.js.map +1 -1
- package/packages/qdrant-skills/dist/index.d.ts +2 -2
- package/packages/qdrant-skills/dist/index.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/index.js +2 -2
- package/packages/qdrant-skills/dist/index.js.map +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/qdrant-skills/src/client.ts +38 -9
- package/packages/qdrant-skills/src/embedder.ts +14 -2
- package/packages/qdrant-skills/src/index.ts +3 -0
- package/packages/qdrant-skills/test/collection-info.test.ts +165 -0
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/dist/index.d.ts +14 -1
- package/packages/skills/dist/index.d.ts.map +1 -1
- package/packages/skills/dist/index.js +3 -1
- package/packages/skills/dist/index.js.map +1 -1
- package/packages/skills/dist/loader.d.ts +209 -0
- package/packages/skills/dist/loader.d.ts.map +1 -1
- package/packages/skills/dist/loader.js +386 -15
- package/packages/skills/dist/loader.js.map +1 -1
- package/packages/skills/dist/remote-source.d.ts +107 -0
- package/packages/skills/dist/remote-source.d.ts.map +1 -0
- package/packages/skills/dist/remote-source.js +79 -0
- package/packages/skills/dist/remote-source.js.map +1 -0
- package/packages/skills/dist/vector-schema.d.ts +58 -0
- package/packages/skills/dist/vector-schema.d.ts.map +1 -0
- package/packages/skills/dist/vector-schema.js +55 -0
- package/packages/skills/dist/vector-schema.js.map +1 -0
- package/packages/skills/package.json +1 -1
- package/packages/skills/src/index.ts +16 -2
- package/packages/skills/src/loader.ts +532 -39
- package/packages/skills/src/remote-source.ts +148 -0
- package/packages/skills/src/vector-schema.ts +72 -0
- package/packages/skills/test/remote-skills.test.ts +292 -0
- package/packages/skills/test/remote-source.test.ts +248 -0
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/sync-to-qdrant.ts +79 -14
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persisted notification-channel settings.
|
|
3
|
+
*
|
|
4
|
+
* The notification center keeps the runtime state of a channel (in memory and
|
|
5
|
+
* in its own SQLite table). This file is the durable record of an EXPLICIT
|
|
6
|
+
* user decision: "LINE is off".
|
|
7
|
+
*
|
|
8
|
+
* Two things need that record:
|
|
9
|
+
*
|
|
10
|
+
* - The startup summary must show "LINE: disabled" instead of a warning
|
|
11
|
+
* after the user turned the channel off. Without a durable record the
|
|
12
|
+
* summary cannot tell "disabled on purpose" from "never configured" - and
|
|
13
|
+
* it must not guess, because guessing wrong shows a warning for a channel
|
|
14
|
+
* the user deliberately turned off.
|
|
15
|
+
* - The decision must survive a restart, so the setting is written to disk
|
|
16
|
+
* when the user changes it.
|
|
17
|
+
*
|
|
18
|
+
* Plain JSON is used on purpose: it works in every runtime (Bun has no
|
|
19
|
+
* usable `node:sqlite`), it is readable by the user, and a broken file can
|
|
20
|
+
* never stop the harness from starting.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
export interface NotificationChannelSettings {
|
|
27
|
+
/** `false` = the user turned the channel off. */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NotificationSettings {
|
|
32
|
+
/** The shape of the file. A future change bumps the version. */
|
|
33
|
+
version: 1;
|
|
34
|
+
channels: Record<string, NotificationChannelSettings>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The directory that holds the harness runtime state. */
|
|
38
|
+
export function notificationSettingsDir(): string {
|
|
39
|
+
return join(homedir(), ".pi-harness-runtime");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The file that holds the persisted channel decisions. */
|
|
43
|
+
export function notificationSettingsPath(): string {
|
|
44
|
+
return join(notificationSettingsDir(), "notification-settings.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function defaultNotificationSettings(): NotificationSettings {
|
|
48
|
+
return { version: 1, channels: {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A plain object - the only shape the decoder accepts. */
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode one channel entry.
|
|
58
|
+
*
|
|
59
|
+
* The file is user-writable, so every value is checked before it is used.
|
|
60
|
+
* An entry that does not decode is dropped - a broken entry must not take
|
|
61
|
+
* the whole file (and with it the other channels) down.
|
|
62
|
+
*/
|
|
63
|
+
function decodeChannelSettings(value: unknown): NotificationChannelSettings | null {
|
|
64
|
+
if (!isRecord(value)) return null;
|
|
65
|
+
return { enabled: value.enabled !== false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the persisted settings.
|
|
70
|
+
*
|
|
71
|
+
* A missing, unreadable or broken file yields the defaults: the settings are
|
|
72
|
+
* a convenience, never a reason to fail a start.
|
|
73
|
+
*/
|
|
74
|
+
export function loadNotificationSettings(path: string = notificationSettingsPath()): NotificationSettings {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(path)) return defaultNotificationSettings();
|
|
77
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
if (!isRecord(parsed)) return defaultNotificationSettings();
|
|
79
|
+
const channels: Record<string, NotificationChannelSettings> = {};
|
|
80
|
+
const rawChannels = parsed.channels;
|
|
81
|
+
if (isRecord(rawChannels)) {
|
|
82
|
+
for (const [id, value] of Object.entries(rawChannels)) {
|
|
83
|
+
const decoded = decodeChannelSettings(value);
|
|
84
|
+
if (decoded) channels[id] = decoded;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { version: 1, channels };
|
|
88
|
+
} catch {
|
|
89
|
+
return defaultNotificationSettings();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Write the settings. Returns false when the file could not be written. */
|
|
94
|
+
export function saveNotificationSettings(
|
|
95
|
+
settings: NotificationSettings,
|
|
96
|
+
path: string = notificationSettingsPath(),
|
|
97
|
+
): boolean {
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Did the user explicitly turn the channel off?
|
|
109
|
+
*
|
|
110
|
+
* A channel that was never mentioned has no entry - it is NOT disabled. Only
|
|
111
|
+
* an explicit decision counts, because "disabled" and "not configured" are
|
|
112
|
+
* different states with different meanings.
|
|
113
|
+
*/
|
|
114
|
+
export function isChannelDisabled(channel: string, path?: string): boolean {
|
|
115
|
+
const settings = loadNotificationSettings(path);
|
|
116
|
+
const entry = settings.channels[channel];
|
|
117
|
+
return entry ? entry.enabled === false : false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record an explicit channel decision.
|
|
122
|
+
*
|
|
123
|
+
* Returns false when the decision could not be persisted - the caller then
|
|
124
|
+
* knows that the decision is lost on the next restart.
|
|
125
|
+
*/
|
|
126
|
+
export function setChannelDisabled(channel: string, disabled: boolean, path?: string): boolean {
|
|
127
|
+
const settings = loadNotificationSettings(path);
|
|
128
|
+
settings.channels[channel] = { enabled: !disabled };
|
|
129
|
+
return saveNotificationSettings(settings, path);
|
|
130
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill service as a source of server-held skills.
|
|
3
|
+
*
|
|
4
|
+
* The service holds the authoritative skill files. It applies the access
|
|
5
|
+
* rules of the user, so a refusal is an ANSWER of the service and not a
|
|
6
|
+
* failure of the client - the two need different fixes and must stay
|
|
7
|
+
* apart.
|
|
8
|
+
*
|
|
9
|
+
* The vector store is an index: it says WHICH skill fits a question, but
|
|
10
|
+
* its payload carries only a truncated body. The service delivers the
|
|
11
|
+
* complete skill, so a machine without a local copy can still use it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getSkillFromSaaS, syncSkillsFromSaaS } from "./skill-sync.js";
|
|
15
|
+
import type {
|
|
16
|
+
RemoteSkill,
|
|
17
|
+
RemoteSkillSource,
|
|
18
|
+
} from "../packages/skills/dist/remote-source.js";
|
|
19
|
+
|
|
20
|
+
/** Why a call to the service failed. */
|
|
21
|
+
export type SkillServiceFailure = "denied" | "unavailable";
|
|
22
|
+
|
|
23
|
+
/** The error a failed call to the service raises. */
|
|
24
|
+
export class SkillServiceError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param message The reason of the failure. Never a credential.
|
|
27
|
+
* @param code Whether the service refused the call or could not be
|
|
28
|
+
* reached. The two need different fixes.
|
|
29
|
+
*/
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
readonly code: SkillServiceFailure,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SkillServiceError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The parts of the sync result the source needs. */
|
|
40
|
+
interface SyncOutcome {
|
|
41
|
+
source: "saas" | "cache" | "none";
|
|
42
|
+
skills?: Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
version?: string;
|
|
47
|
+
updatedAt?: string;
|
|
48
|
+
}>;
|
|
49
|
+
error?: string;
|
|
50
|
+
failure?: SkillServiceFailure;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The parts of the sync module the source needs. */
|
|
54
|
+
export interface SkillServiceGateway {
|
|
55
|
+
syncSkillsFromSaaS(options?: {
|
|
56
|
+
forceRefresh?: boolean;
|
|
57
|
+
}): Promise<SyncOutcome>;
|
|
58
|
+
getSkillFromSaaS(identifier: {
|
|
59
|
+
id?: string;
|
|
60
|
+
slug?: string;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
description: string;
|
|
65
|
+
version?: string;
|
|
66
|
+
updatedAt?: string;
|
|
67
|
+
prompt?: string;
|
|
68
|
+
} | null>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The service as a source of remote skills.
|
|
73
|
+
*
|
|
74
|
+
* The gateway is injected, so the source can be tested without a service
|
|
75
|
+
* and without a network.
|
|
76
|
+
*/
|
|
77
|
+
export class SkillServiceSource implements RemoteSkillSource {
|
|
78
|
+
/** @param gateway The sync module. The real one by default. */
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly gateway: SkillServiceGateway = {
|
|
81
|
+
syncSkillsFromSaaS,
|
|
82
|
+
getSkillFromSaaS,
|
|
83
|
+
},
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The skills the service holds.
|
|
88
|
+
*
|
|
89
|
+
* A service that refuses the call raises a `SkillServiceError` with
|
|
90
|
+
* the code "denied" - the caller can tell that apart from a service
|
|
91
|
+
* that could not be reached.
|
|
92
|
+
*/
|
|
93
|
+
async list(options?: {
|
|
94
|
+
search?: string;
|
|
95
|
+
limit?: number;
|
|
96
|
+
}): Promise<RemoteSkill[]> {
|
|
97
|
+
const outcome = await this.gateway.syncSkillsFromSaaS();
|
|
98
|
+
|
|
99
|
+
if (outcome.source === "none") {
|
|
100
|
+
throw new SkillServiceError(
|
|
101
|
+
outcome.error ?? "the skill service could not be reached",
|
|
102
|
+
outcome.failure ?? "unavailable",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const skills = outcome.skills ?? [];
|
|
107
|
+
const searched = options?.search
|
|
108
|
+
? skills.filter((skill) => matches(skill, options.search as string))
|
|
109
|
+
: skills;
|
|
110
|
+
const limited = options?.limit ? searched.slice(0, options.limit) : searched;
|
|
111
|
+
|
|
112
|
+
return limited.map((skill) => ({
|
|
113
|
+
id: skill.id,
|
|
114
|
+
name: skill.name,
|
|
115
|
+
description: skill.description,
|
|
116
|
+
version: skill.version,
|
|
117
|
+
updatedAt: skill.updatedAt,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The complete content of one skill.
|
|
123
|
+
*
|
|
124
|
+
* The service returns the whole skill, so the content is complete -
|
|
125
|
+
* unlike the truncated body of a vector store payload.
|
|
126
|
+
*/
|
|
127
|
+
async get(id: string): Promise<RemoteSkill | null> {
|
|
128
|
+
const skill = await this.gateway.getSkillFromSaaS({ id });
|
|
129
|
+
if (!skill) return null;
|
|
130
|
+
return {
|
|
131
|
+
id: skill.id,
|
|
132
|
+
name: skill.name,
|
|
133
|
+
description: skill.description,
|
|
134
|
+
version: skill.version,
|
|
135
|
+
updatedAt: skill.updatedAt,
|
|
136
|
+
content: skill.prompt,
|
|
137
|
+
contentComplete: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a skill matches a search term.
|
|
144
|
+
*
|
|
145
|
+
* The service does the real search; this filter only narrows what the
|
|
146
|
+
* service already returned.
|
|
147
|
+
*/
|
|
148
|
+
function matches(
|
|
149
|
+
skill: { name: string; description: string },
|
|
150
|
+
term: string,
|
|
151
|
+
): boolean {
|
|
152
|
+
const needle = term.toLowerCase();
|
|
153
|
+
return (
|
|
154
|
+
skill.name.toLowerCase().includes(needle) ||
|
|
155
|
+
skill.description.toLowerCase().includes(needle)
|
|
156
|
+
);
|
|
157
|
+
}
|
package/harness/skill-sync.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Falls back gracefully if backend is unavailable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync,
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import type {
|
|
@@ -13,6 +13,8 @@ import type {
|
|
|
13
13
|
GetSkillResult,
|
|
14
14
|
} from "../packages/skill-mcp-client/src/types.js";
|
|
15
15
|
import { SkillAPIError } from "../packages/skill-mcp-client/dist/errors.js";
|
|
16
|
+
import { atomicWriteFileSync } from "../packages/cookie-sanitizer/dist/index.js";
|
|
17
|
+
import { centralWarn } from "../packages/logging/dist/index.js";
|
|
16
18
|
|
|
17
19
|
type SkillMCPClient = InstanceType<
|
|
18
20
|
typeof import("../packages/skill-mcp-client/dist/client.js").SkillMCPClient
|
|
@@ -52,7 +54,11 @@ async function getClient(): Promise<SkillMCPClient | null> {
|
|
|
52
54
|
});
|
|
53
55
|
return clientInstance;
|
|
54
56
|
} catch {
|
|
55
|
-
|
|
57
|
+
centralWarn(
|
|
58
|
+
"harness.skill-sync",
|
|
59
|
+
"service.sync-failed",
|
|
60
|
+
"the skill client package could not be loaded",
|
|
61
|
+
);
|
|
56
62
|
return null;
|
|
57
63
|
}
|
|
58
64
|
}
|
|
@@ -73,6 +79,22 @@ interface SkillSyncResult {
|
|
|
73
79
|
skillCount: number;
|
|
74
80
|
source: "saas" | "cache" | "none";
|
|
75
81
|
error?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The skills the service holds.
|
|
84
|
+
*
|
|
85
|
+
* The list is carried along with the count, so a caller that needs
|
|
86
|
+
* the skills does not have to ask the service a second time.
|
|
87
|
+
*/
|
|
88
|
+
skills?: SkillInfo[];
|
|
89
|
+
/**
|
|
90
|
+
* Why the service could not be reached.
|
|
91
|
+
*
|
|
92
|
+
* "denied" means the service ANSWERED and refused the call - the
|
|
93
|
+
* token is missing or the plan does not cover the skill. That is a
|
|
94
|
+
* different situation than a service that could not be reached at
|
|
95
|
+
* all, and the user needs a different fix for each.
|
|
96
|
+
*/
|
|
97
|
+
failure?: "denied" | "unavailable";
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/**
|
|
@@ -102,22 +124,28 @@ function getCachedSkills(): CacheData | null {
|
|
|
102
124
|
*/
|
|
103
125
|
function saveCache(data: CacheData): void {
|
|
104
126
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
127
|
+
// The write is atomic: a crash in the middle leaves the previous
|
|
128
|
+
// cache intact instead of a half written file.
|
|
129
|
+
atomicWriteFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
109
130
|
} catch {
|
|
110
|
-
|
|
131
|
+
centralWarn(
|
|
132
|
+
"harness.skill-sync",
|
|
133
|
+
"service.cache-write-failed",
|
|
134
|
+
"the skill cache could not be written",
|
|
135
|
+
);
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
114
139
|
/**
|
|
115
140
|
* Fetch and cache skills from SaaS backend
|
|
116
141
|
*/
|
|
117
|
-
async function fetchFromSaaS(): Promise<
|
|
142
|
+
async function fetchFromSaaS(): Promise<{
|
|
143
|
+
data: CacheData | null;
|
|
144
|
+
failure?: "denied" | "unavailable";
|
|
145
|
+
}> {
|
|
118
146
|
const client = await getClient();
|
|
119
147
|
if (!client) {
|
|
120
|
-
return null;
|
|
148
|
+
return { data: null, failure: "unavailable" };
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
try {
|
|
@@ -128,16 +156,26 @@ async function fetchFromSaaS(): Promise<CacheData | null> {
|
|
|
128
156
|
version: "1.0.0",
|
|
129
157
|
};
|
|
130
158
|
saveCache(cacheData);
|
|
131
|
-
return cacheData;
|
|
159
|
+
return { data: cacheData };
|
|
132
160
|
} catch (error) {
|
|
133
161
|
if (error instanceof SkillAPIError) {
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
centralWarn(
|
|
163
|
+
"harness.skill-sync",
|
|
164
|
+
"service.sync-failed",
|
|
165
|
+
`the skill service answered with an error (code ${error.code})`,
|
|
136
166
|
);
|
|
167
|
+
// The service answered and refused the call.
|
|
168
|
+
if (error.isAuthError() || error.isTierRestricted()) {
|
|
169
|
+
return { data: null, failure: "denied" };
|
|
170
|
+
}
|
|
137
171
|
} else {
|
|
138
|
-
|
|
172
|
+
centralWarn(
|
|
173
|
+
"harness.skill-sync",
|
|
174
|
+
"service.sync-failed",
|
|
175
|
+
"the skill service could not be reached",
|
|
176
|
+
);
|
|
139
177
|
}
|
|
140
|
-
return null;
|
|
178
|
+
return { data: null, failure: "unavailable" };
|
|
141
179
|
}
|
|
142
180
|
}
|
|
143
181
|
|
|
@@ -162,6 +200,7 @@ export async function syncSkillsFromSaaS(
|
|
|
162
200
|
success: true,
|
|
163
201
|
skillCount: 0,
|
|
164
202
|
source: "none",
|
|
203
|
+
skills: [],
|
|
165
204
|
error: "SKILLS_SAAS_API_KEY not configured",
|
|
166
205
|
};
|
|
167
206
|
}
|
|
@@ -174,17 +213,19 @@ export async function syncSkillsFromSaaS(
|
|
|
174
213
|
success: true,
|
|
175
214
|
skillCount: cached.skills.length,
|
|
176
215
|
source: "cache",
|
|
216
|
+
skills: cached.skills,
|
|
177
217
|
};
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
// Fetch from SaaS
|
|
182
|
-
const
|
|
183
|
-
if (data) {
|
|
222
|
+
const fetched = await fetchFromSaaS();
|
|
223
|
+
if (fetched.data) {
|
|
184
224
|
return {
|
|
185
225
|
success: true,
|
|
186
|
-
skillCount: data.skills.length,
|
|
226
|
+
skillCount: fetched.data.skills.length,
|
|
187
227
|
source: "saas",
|
|
228
|
+
skills: fetched.data.skills,
|
|
188
229
|
};
|
|
189
230
|
}
|
|
190
231
|
|
|
@@ -195,16 +236,23 @@ export async function syncSkillsFromSaaS(
|
|
|
195
236
|
success: true,
|
|
196
237
|
skillCount: staleCache.skills.length,
|
|
197
238
|
source: "cache",
|
|
239
|
+
skills: staleCache.skills,
|
|
198
240
|
error: "Using stale cache (SaaS unavailable)",
|
|
241
|
+
failure: fetched.failure,
|
|
199
242
|
};
|
|
200
243
|
}
|
|
201
244
|
|
|
202
245
|
// Everything failed, return empty
|
|
203
246
|
return {
|
|
204
|
-
success:
|
|
247
|
+
success: false,
|
|
205
248
|
skillCount: 0,
|
|
206
249
|
source: "none",
|
|
207
|
-
|
|
250
|
+
skills: [],
|
|
251
|
+
error:
|
|
252
|
+
fetched.failure === "denied"
|
|
253
|
+
? "The skill service refused the call - the token or the plan does not allow it"
|
|
254
|
+
: "Could not reach Skills SaaS backend",
|
|
255
|
+
failure: fetched.failure,
|
|
208
256
|
};
|
|
209
257
|
}
|
|
210
258
|
|
|
@@ -226,7 +274,11 @@ export async function getSkillFromSaaS(identifier: {
|
|
|
226
274
|
return await client.getSkill(identifier);
|
|
227
275
|
} catch (error) {
|
|
228
276
|
if (error instanceof SkillAPIError) {
|
|
229
|
-
|
|
277
|
+
centralWarn(
|
|
278
|
+
"harness.skill-sync",
|
|
279
|
+
"service.skill-fetch-failed",
|
|
280
|
+
`the skill service could not deliver a skill (code ${error.code})`,
|
|
281
|
+
);
|
|
230
282
|
}
|
|
231
283
|
return null;
|
|
232
284
|
}
|
|
@@ -236,7 +288,7 @@ export async function getSkillFromSaaS(identifier: {
|
|
|
236
288
|
* Check if skill sync is configured
|
|
237
289
|
*/
|
|
238
290
|
export function isSkillSyncConfigured(): boolean {
|
|
239
|
-
return
|
|
291
|
+
return Boolean(process.env.SKILLS_SAAS_API_KEY);
|
|
240
292
|
}
|
|
241
293
|
|
|
242
294
|
/**
|
|
@@ -250,7 +302,7 @@ export function getSyncStatus(): {
|
|
|
250
302
|
const cache = getCachedSkills();
|
|
251
303
|
return {
|
|
252
304
|
configured: isSkillSyncConfigured(),
|
|
253
|
-
cacheValid:
|
|
305
|
+
cacheValid: Boolean(cache),
|
|
254
306
|
cachedSkills: cache?.skills.length ?? 0,
|
|
255
307
|
};
|
|
256
308
|
}
|