@akagilnc/pi-workflow-roles 0.1.2041 → 0.1.2048
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/extensions/role-runtime.ts +5 -0
- package/package.json +1 -1
- package/src/oauth-keepalive.ts +216 -0
- package/src/role-runtime.ts +14 -0
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { loadCanonicalSkillBinding as loadHomeCanonicalSkillBinding } from "../src/canonical-skill-binding.ts";
|
|
34
34
|
import { loadPackagedCanonicalSkillBinding } from "../src/package-resources/method-skill-binding.ts";
|
|
35
35
|
import { JUDGE_OUTPUT_TOOL_NAME } from "../src/package-contracts/judge-output.ts";
|
|
36
|
+
import { readOAuthKeepaliveProviders } from "../src/oauth-keepalive.ts";
|
|
36
37
|
import {
|
|
37
38
|
createProductionMergerGitState,
|
|
38
39
|
createRoleRuntimeExtension,
|
|
@@ -244,7 +245,11 @@ export default function roleRuntime(pi: ExtensionAPI): void {
|
|
|
244
245
|
const reviewerAgent = createReviewerAgentRunner();
|
|
245
246
|
registerNavigatorModelCommand(pi);
|
|
246
247
|
const navigatorSessionFactory = createNativeNavigatorSessionFactory();
|
|
248
|
+
// #351: static provider list from extension setting (default ["kimi-coding"]).
|
|
249
|
+
// Production root is the sole reader; keepalive never auto-detects providers.
|
|
250
|
+
const oauthKeepaliveProviders = readOAuthKeepaliveProviders();
|
|
247
251
|
createRoleRuntimeExtension({
|
|
252
|
+
oauthKeepalive: { providers: oauthKeepaliveProviders },
|
|
248
253
|
loadJudgeSoul: () => readFile(judgeSoulPath, "utf8"),
|
|
249
254
|
loadFixerSoul: () => readFile(fixerSoulPath, "utf8"),
|
|
250
255
|
loadFixPacket: (path) => readFile(path, "utf8"),
|
package/package.json
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-side OAuth credential keepalive (#351).
|
|
3
|
+
*
|
|
4
|
+
* Periodically calls the public `ctx.modelRegistry.refresh` facade so expired
|
|
5
|
+
* OAuth tokens (e.g. kimi-coding 900s TTL) are renewed during long sessions.
|
|
6
|
+
* Does not read auth.json, touch pi-ai private APIs, or reuse the 183s idle gate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_OAUTH_KEEPALIVE_PROVIDERS = ["kimi-coding"] as const;
|
|
14
|
+
|
|
15
|
+
export const OAUTH_KEEPALIVE_INTERVAL_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
/** Persistent extension setting filename under PI_CODING_AGENT_DIR / ~/.pi/agent. */
|
|
18
|
+
export const OAUTH_KEEPALIVE_SETTING_FILENAME = "oauth-keepalive.json";
|
|
19
|
+
|
|
20
|
+
/** Path to the static provider-list extension setting (default ["kimi-coding"]). */
|
|
21
|
+
export function oauthKeepaliveSettingPath(): string {
|
|
22
|
+
return join(
|
|
23
|
+
process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
|
|
24
|
+
OAUTH_KEEPALIVE_SETTING_FILENAME,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isExactRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the static keepalive provider list from the extension setting file.
|
|
34
|
+
* Missing file → default ["kimi-coding"]. Malformed content fails closed.
|
|
35
|
+
* Production extension root is the sole caller that feeds this into keepalive.
|
|
36
|
+
*/
|
|
37
|
+
export function readOAuthKeepaliveProviders(
|
|
38
|
+
path = oauthKeepaliveSettingPath(),
|
|
39
|
+
): readonly string[] {
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
42
|
+
if (!isExactRecord(raw) || !Array.isArray(raw.providers)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"OAuth keepalive setting is malformed: expected { providers: string[] }",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const providers = raw.providers.map((id, index) => {
|
|
48
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`OAuth keepalive setting providers[${index}] must be a non-empty string`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
// Accept via trim-non-empty; return the normalized id so refresh can hit real providers.
|
|
54
|
+
return id.trim();
|
|
55
|
+
});
|
|
56
|
+
return Object.freeze(providers);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Absent optional setting uses the documented default; all other causes propagate.
|
|
59
|
+
if (
|
|
60
|
+
error instanceof Error &&
|
|
61
|
+
"code" in error &&
|
|
62
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
63
|
+
) {
|
|
64
|
+
return DEFAULT_OAUTH_KEEPALIVE_PROVIDERS;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Injected interval scheduler (same shape as TrajectoryScheduler). */
|
|
71
|
+
export type OAuthKeepaliveScheduler = {
|
|
72
|
+
/** Schedule `tick` every `ms` milliseconds; return a cancel function. */
|
|
73
|
+
every: (ms: number, tick: () => void) => () => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type OAuthKeepaliveOptions = {
|
|
77
|
+
/** Static provider id list; frozen at construction. Default: ["kimi-coding"]. */
|
|
78
|
+
providers?: readonly string[];
|
|
79
|
+
/** Interval between ticks in ms. Default: 60_000. First tick is after one interval. */
|
|
80
|
+
intervalMs?: number;
|
|
81
|
+
/** Injectable scheduler for tests. Default: setInterval + unref. */
|
|
82
|
+
scheduler?: OAuthKeepaliveScheduler;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Minimal context surface used by keepalive (public extension facade only). */
|
|
86
|
+
export type OAuthKeepaliveContext = {
|
|
87
|
+
modelRegistry: {
|
|
88
|
+
refresh: (options?: {
|
|
89
|
+
allowNetwork?: boolean;
|
|
90
|
+
providers?: readonly string[];
|
|
91
|
+
force?: boolean;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
}) => Promise<{
|
|
94
|
+
aborted: boolean;
|
|
95
|
+
errors: ReadonlyMap<string, Error>;
|
|
96
|
+
}>;
|
|
97
|
+
};
|
|
98
|
+
ui?: {
|
|
99
|
+
notify?: (message: string, type?: "info" | "warning" | "error") => void;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type OAuthKeepalive = {
|
|
104
|
+
start(ctx: OAuthKeepaliveContext): void;
|
|
105
|
+
stop(): void;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const defaultScheduler: OAuthKeepaliveScheduler = {
|
|
109
|
+
every(ms, tick) {
|
|
110
|
+
const timer = setInterval(tick, ms);
|
|
111
|
+
// Allow the process to exit if a caller forgets stop (same discipline as trajectory).
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
return () => clearInterval(timer);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function errorClassName(error: unknown): string {
|
|
118
|
+
if (error instanceof Error && error.name.trim() !== "") return error.name;
|
|
119
|
+
if (error !== null && typeof error === "object" && "name" in error) {
|
|
120
|
+
const name = (error as { name?: unknown }).name;
|
|
121
|
+
if (typeof name === "string" && name.trim() !== "") return name;
|
|
122
|
+
}
|
|
123
|
+
return "Error";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatWarning(providerId: string, error: unknown): string {
|
|
127
|
+
const cls = errorClassName(error);
|
|
128
|
+
const detail =
|
|
129
|
+
error instanceof Error
|
|
130
|
+
? error.message
|
|
131
|
+
: typeof error === "string"
|
|
132
|
+
? error
|
|
133
|
+
: String(error);
|
|
134
|
+
return `oauth-keepalive: refresh failed for provider ${providerId} (${cls}): ${detail}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function emitWarning(ctx: OAuthKeepaliveContext, providerId: string, error: unknown): void {
|
|
138
|
+
const text = formatWarning(providerId, error);
|
|
139
|
+
// Exactly one visible surface per failure: prefer ui.notify when present;
|
|
140
|
+
// console.warn only when notify is unavailable (print/json / headless)
|
|
141
|
+
// or when notify itself throws (still one surface; carry the notify cause).
|
|
142
|
+
const notify = ctx.ui?.notify;
|
|
143
|
+
if (typeof notify === "function") {
|
|
144
|
+
try {
|
|
145
|
+
notify(text, "warning");
|
|
146
|
+
} catch (notifyError) {
|
|
147
|
+
// Notify threw: fall back once so refresh failure stays visible.
|
|
148
|
+
console.warn(text, notifyError);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.warn(text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Session-lifecycle owner for periodic OAuth refresh via the public modelRegistry facade.
|
|
157
|
+
* Single-flight: overlapping ticks are skipped. stop() cancels the interval and aborts in-flight work.
|
|
158
|
+
*/
|
|
159
|
+
export function createOAuthKeepalive(options: OAuthKeepaliveOptions = {}): OAuthKeepalive {
|
|
160
|
+
const providers = Object.freeze(
|
|
161
|
+
(options.providers ?? DEFAULT_OAUTH_KEEPALIVE_PROVIDERS).map((id) => id),
|
|
162
|
+
) as readonly string[];
|
|
163
|
+
const intervalMs = options.intervalMs ?? OAUTH_KEEPALIVE_INTERVAL_MS;
|
|
164
|
+
const scheduler = options.scheduler ?? defaultScheduler;
|
|
165
|
+
|
|
166
|
+
let cancel: (() => void) | undefined;
|
|
167
|
+
let abortController: AbortController | undefined;
|
|
168
|
+
/** Bare single-flight gate; survives stop/start until the in-flight call settles. */
|
|
169
|
+
let inFlight = false;
|
|
170
|
+
|
|
171
|
+
const stop = (): void => {
|
|
172
|
+
cancel?.();
|
|
173
|
+
cancel = undefined;
|
|
174
|
+
abortController?.abort();
|
|
175
|
+
abortController = undefined;
|
|
176
|
+
// Do not clear unsettled inFlight — single-flight is continuous across stop/start.
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const runTick = async (ctx: OAuthKeepaliveContext): Promise<void> => {
|
|
180
|
+
if (inFlight) return;
|
|
181
|
+
const ac = abortController;
|
|
182
|
+
if (ac === undefined || ac.signal.aborted) return;
|
|
183
|
+
inFlight = true;
|
|
184
|
+
try {
|
|
185
|
+
const result = await ctx.modelRegistry.refresh({
|
|
186
|
+
providers: [...providers],
|
|
187
|
+
allowNetwork: true,
|
|
188
|
+
signal: ac.signal,
|
|
189
|
+
});
|
|
190
|
+
if (ac.signal.aborted || result.aborted) return;
|
|
191
|
+
for (const [providerId, error] of result.errors) {
|
|
192
|
+
emitWarning(ctx, providerId, error);
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (ac.signal.aborted) return;
|
|
196
|
+
// Whole-call failure: one warning per configured provider (ticket error surface).
|
|
197
|
+
for (const providerId of providers) {
|
|
198
|
+
emitWarning(ctx, providerId, error);
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
inFlight = false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
start(ctx) {
|
|
207
|
+
// Reload / re-start: tear down any prior interval before hanging a new one.
|
|
208
|
+
stop();
|
|
209
|
+
abortController = new AbortController();
|
|
210
|
+
cancel = scheduler.every(intervalMs, () => {
|
|
211
|
+
void runTick(ctx);
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
stop,
|
|
215
|
+
};
|
|
216
|
+
}
|
package/src/role-runtime.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
|
|
25
25
|
import { installWorkerGitHooks } from "./worker-submission-gates.ts";
|
|
26
26
|
import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
|
|
27
|
+
import { createOAuthKeepalive, type OAuthKeepaliveOptions } from "./oauth-keepalive.ts";
|
|
27
28
|
|
|
28
29
|
import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
|
|
29
30
|
import type { CollectorClock } from "./collector-evidence.ts";
|
|
@@ -506,6 +507,12 @@ export type RoleRuntimeDependencies = {
|
|
|
506
507
|
/** Monotonic ms clock for update throttling; defaults to performance.now (not Date.now). */
|
|
507
508
|
toolExecutionObservationMonoNow?(): number;
|
|
508
509
|
toolExecutionObservationWriter?: ToolExecutionObservationWriter;
|
|
510
|
+
/**
|
|
511
|
+
* #351 OAuth keepalive: providers/interval/scheduler.
|
|
512
|
+
* Production extension root reads oauth-keepalive.json and passes providers here
|
|
513
|
+
* (default ["kimi-coding"] when the setting file is absent). Tests may inject.
|
|
514
|
+
*/
|
|
515
|
+
oauthKeepalive?: OAuthKeepaliveOptions;
|
|
509
516
|
};
|
|
510
517
|
|
|
511
518
|
function abortContext(ctx: ExtensionContext): void {
|
|
@@ -577,6 +584,8 @@ export function createRoleRuntimeExtension(
|
|
|
577
584
|
let reviewerOriginalRequest: string | undefined;
|
|
578
585
|
let reviewerExpansionCaptured = false;
|
|
579
586
|
let navigatorAttendance: NavigatorAttendanceDependency | undefined;
|
|
587
|
+
// #351: session-lifecycle owner for periodic OAuth refresh (orthogonal to role admission).
|
|
588
|
+
const oauthKeepalive = createOAuthKeepalive(dependencies.oauthKeepalive);
|
|
580
589
|
let pendingNavigatorPresentation: { event: import("./navigator-attendance.ts").NavigatorEvent; report: import("./navigator-attendance.ts").NavigatorReport } | undefined;
|
|
581
590
|
let pendingNavigatorSettlement: Promise<void> | undefined;
|
|
582
591
|
let navigatorWorkContext: NavigatorWorkContext | undefined;
|
|
@@ -809,6 +818,8 @@ export function createRoleRuntimeExtension(
|
|
|
809
818
|
}, { triggerTurn: false });
|
|
810
819
|
});
|
|
811
820
|
pi.on("session_shutdown", async () => {
|
|
821
|
+
// #351: stop OAuth keepalive first so shutdown yields zero further ticks.
|
|
822
|
+
oauthKeepalive.stop();
|
|
812
823
|
// Flush any still-pending affirmative attendance before teardown. Accepted
|
|
813
824
|
// grace-timeout paths normally emit on agent_settled; abort can skip that hook.
|
|
814
825
|
const presentation = pendingNavigatorPresentation;
|
|
@@ -1043,6 +1054,9 @@ export function createRoleRuntimeExtension(
|
|
|
1043
1054
|
pendingNavigatorSettlement = undefined;
|
|
1044
1055
|
pendingInfrastructureToolCallIds.clear();
|
|
1045
1056
|
navigatorWorkContext = undefined;
|
|
1057
|
+
// #351: OAuth keepalive is orthogonal to --ak-role; start before role early-return
|
|
1058
|
+
// so role-less sessions (and reload after shutdown stop) still keep tokens alive.
|
|
1059
|
+
oauthKeepalive.start(ctx);
|
|
1046
1060
|
const rawRole = pi.getFlag(ROLE_FLAG.name);
|
|
1047
1061
|
if (rawRole === undefined) return;
|
|
1048
1062
|
const entry = PACKAGED_ROLE_REGISTRY.find(({ role }) => role === rawRole);
|