job-application-agent 3.3.0 → 3.4.1
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/README.md +11 -3
- package/installer/src/installer.mjs +21 -4
- package/job-application-agent/SKILL.md +28 -17
- package/job-application-agent/capabilities.json +5 -0
- package/job-application-agent/references/ANALYTICS.md +24 -7
- package/job-application-agent/references/CLOUD_STATE.md +19 -0
- package/job-application-agent/references/RUNS.md +44 -0
- package/job-application-agent/references/SCHEMAS.md +9 -3
- package/job-application-agent/references/SOURCES.md +1 -1
- package/job-application-agent/references/VPS_CLIENTS.md +33 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +384 -0
- package/job-application-agent/scripts/job-application.mjs +247 -19
- package/job-application-agent/scripts/telemetry-client.mjs +35 -8
- package/job-application-agent/scripts/telemetry-schema.mjs +30 -3
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/cloud-state-client.test.mjs +105 -0
- package/job-application-agent/tests/job-application.test.mjs +96 -0
- package/job-application-agent/tests/linux-secret-service.integration.test.mjs +1 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +91 -2
- package/job-application-agent/tests/telemetry-schema.test.mjs +22 -0
- package/job-application-agent/tests/workflow-state.test.mjs +75 -3
- package/package.json +2 -2
|
@@ -12,6 +12,7 @@ import { SourceCommunityClient } from './source-community-client.mjs';
|
|
|
12
12
|
import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
|
|
13
13
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
14
14
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
15
|
+
import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
|
|
15
16
|
|
|
16
17
|
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
17
18
|
const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
|
|
@@ -37,6 +38,10 @@ const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url
|
|
|
37
38
|
const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
|
|
38
39
|
if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
|
|
39
40
|
const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
|
|
41
|
+
const MIN_DISCOVERY_SOURCES = 3;
|
|
42
|
+
const CONCENTRATION_THRESHOLD = 60;
|
|
43
|
+
const SOURCE_BLOCKERS = new Set(['login', 'mfa', 'captcha', 'site-error', 'access-unavailable']);
|
|
44
|
+
const CONCENTRATION_REASONS = new Set(['stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed']);
|
|
40
45
|
const COMMUNITY_SOURCE_ID = /^community-[0-9a-f]{16}$/;
|
|
41
46
|
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
42
47
|
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
@@ -63,6 +68,17 @@ function stateDir() {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
const secretStore = createSecretStore({ stateDir });
|
|
71
|
+
const cloudState = new CloudStateClient({ stateDir: stateDir(), configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
72
|
+
|
|
73
|
+
function cachedCloudProfileRaw() {
|
|
74
|
+
try {
|
|
75
|
+
const config = JSON.parse(readFileSync(process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath(), 'utf8'));
|
|
76
|
+
if (config.version !== 2) return null;
|
|
77
|
+
return object(JSON.parse(readFileSync(join(stateDir(), 'cloud-profile-cache.json'), 'utf8')), 'profile');
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
66
82
|
|
|
67
83
|
export function durationBucket(milliseconds) {
|
|
68
84
|
if (milliseconds < 1_000) return 'under-1s';
|
|
@@ -600,6 +616,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
600
616
|
}
|
|
601
617
|
|
|
602
618
|
function storedProfileRaw() {
|
|
619
|
+
const cloudProfile = cachedCloudProfileRaw();
|
|
620
|
+
if (cloudProfile) return cloudProfile;
|
|
603
621
|
try { return object(JSON.parse(secretStore.readProfile()), 'profile'); } catch (error) {
|
|
604
622
|
if (/missing or unreadable|could not read|could not store|Windows profile storage|not supported on this platform|secret-tool is not installed/i.test(error.message)) throw error;
|
|
605
623
|
throw new Error('The stored profile is missing or unreadable. Run profile set again.');
|
|
@@ -859,6 +877,7 @@ async function autonomyGrant(input) {
|
|
|
859
877
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown autonomy grant property: ${key}.`);
|
|
860
878
|
if (value.mode !== 'routine-auto') throw new Error('autonomy grant mode must be routine-auto.');
|
|
861
879
|
const grant = { version: 1, enabled: true, mode: 'routine-auto', scopes: [...AUTONOMY_SCOPES], grantedAt: new Date().toISOString() };
|
|
880
|
+
if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', grant);
|
|
862
881
|
const file = join(await ensureStateDir(), 'autonomy.json');
|
|
863
882
|
await writePrivateJson(file, grant);
|
|
864
883
|
return autonomyView(grant);
|
|
@@ -868,14 +887,21 @@ async function autonomyRevoke() {
|
|
|
868
887
|
const file = join(await ensureStateDir(), 'autonomy.json');
|
|
869
888
|
const current = await readPrivateJson(file, { version: 1, enabled: false });
|
|
870
889
|
const revoked = { version: 1, enabled: false, revokedAt: new Date().toISOString(), ...(current.grantedAt ? { grantedAt: current.grantedAt } : {}) };
|
|
890
|
+
if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', revoked);
|
|
871
891
|
await writePrivateJson(file, revoked);
|
|
872
892
|
return autonomyView(revoked);
|
|
873
893
|
}
|
|
874
894
|
|
|
875
895
|
async function storeProfile(profileInput) {
|
|
876
896
|
const profile = validateProfile(profileInput);
|
|
877
|
-
if (
|
|
878
|
-
|
|
897
|
+
if (await cloudState.configured()) {
|
|
898
|
+
const dir = await ensureStateDir();
|
|
899
|
+
await cloudState.putDocumentCurrent('profile', profile);
|
|
900
|
+
await writePrivateJson(join(dir, 'cloud-profile-cache.json'), profile);
|
|
901
|
+
} else {
|
|
902
|
+
if (process.platform === 'win32') await ensureStateDir();
|
|
903
|
+
secretStore.writeProfile(JSON.stringify(profile));
|
|
904
|
+
}
|
|
879
905
|
return profile;
|
|
880
906
|
}
|
|
881
907
|
|
|
@@ -922,11 +948,23 @@ async function importResume(source) {
|
|
|
922
948
|
await chmod(target, 0o600);
|
|
923
949
|
const metadata = { source: sourceLabel, importedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
924
950
|
await writeFile(join(dir, 'resume.json'), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
951
|
+
if (await cloudState.configured()) {
|
|
952
|
+
await cloudState.putFileCurrent('resume.pdf', bytes);
|
|
953
|
+
await cloudState.putFileCurrent('resume.json', Buffer.from(JSON.stringify(metadata)));
|
|
954
|
+
}
|
|
925
955
|
return { path: target, sha256: metadata.sha256, bytes: metadata.bytes };
|
|
926
956
|
}
|
|
927
957
|
|
|
928
958
|
async function canonicalResumePath() {
|
|
929
959
|
const target = join(await ensureStateDir(), 'resume.pdf');
|
|
960
|
+
if (await cloudState.configured()) {
|
|
961
|
+
try { await cloudState.fetchResume(); }
|
|
962
|
+
catch (error) {
|
|
963
|
+
// Cached research and browser preparation remain usable during an
|
|
964
|
+
// outage. Integrity, authentication, and checksum failures still stop.
|
|
965
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
930
968
|
try {
|
|
931
969
|
const details = await stat(target);
|
|
932
970
|
if (!details.isFile()) throw new Error('Canonical resume path is not a file. Import the resume again.');
|
|
@@ -1005,7 +1043,7 @@ async function ledgerCheck(candidate) {
|
|
|
1005
1043
|
return duplicateResult(entries, candidate, outcomes);
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1008
|
-
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
|
|
1046
|
+
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride, cloudIntent = null) {
|
|
1009
1047
|
const entry = validateLedgerEntry(entryInput);
|
|
1010
1048
|
return withStateLock('applications', async (dir) => {
|
|
1011
1049
|
const file = join(dir, 'applications.ndjson');
|
|
@@ -1035,15 +1073,30 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride)
|
|
|
1035
1073
|
await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
|
|
1036
1074
|
await chmod(roundsFile, 0o600);
|
|
1037
1075
|
}
|
|
1038
|
-
|
|
1076
|
+
if (await cloudState.configured()) {
|
|
1077
|
+
if (cloudIntent?.intentId && cloudIntent?.leaseId) {
|
|
1078
|
+
await cloudState.confirmIntent(cloudIntent.intentId, storedEntry, cloudIntent.leaseId, `application:${storedEntry.id}:${storedEntry.submittedAt}`);
|
|
1079
|
+
} else {
|
|
1080
|
+
await cloudState.appendRecord('applications', storedEntry, { recordKey: storedEntry.id, idempotencyKey: `application:${storedEntry.id}:${storedEntry.submittedAt}`, occurredAt: storedEntry.submittedAt, queueOnFailure: true });
|
|
1081
|
+
if (entry.roundId) await cloudState.appendRecord('rounds', { type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt }, { recordKey: entry.roundId, idempotencyKey: `round-confirmation:${entry.roundId}:${entry.id}`, occurredAt: entry.submittedAt, queueOnFailure: true });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
|
|
1039
1085
|
});
|
|
1040
1086
|
}
|
|
1041
1087
|
|
|
1088
|
+
async function appendCloudEvent(name, event) {
|
|
1089
|
+
if (!await cloudState.configured()) return;
|
|
1090
|
+
const key = event.id ?? event.roundId ?? event.applicationId ?? randomUUID();
|
|
1091
|
+
await cloudState.appendRecord(name, event, { recordKey: key, idempotencyKey: `${name}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`, occurredAt: event.occurredAt, queueOnFailure: true });
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1042
1094
|
async function appendPrivateEvent(name, event) {
|
|
1043
1095
|
return withStateLock(name, async (dir) => {
|
|
1044
1096
|
const file = join(dir, `${name}.ndjson`);
|
|
1045
1097
|
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1046
1098
|
await chmod(file, 0o600);
|
|
1099
|
+
if (['rounds', 'discovery', 'attention', 'friction'].includes(name)) await appendCloudEvent(name, event);
|
|
1047
1100
|
return event;
|
|
1048
1101
|
});
|
|
1049
1102
|
}
|
|
@@ -1065,7 +1118,84 @@ async function roundStart(input) {
|
|
|
1065
1118
|
occurredAt: isoDate(value.startedAt, 'round.startedAt'),
|
|
1066
1119
|
};
|
|
1067
1120
|
await appendPrivateEvent('rounds', event);
|
|
1068
|
-
return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false };
|
|
1121
|
+
return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false, discoveryPolicy: { minSources: MIN_DISCOVERY_SOURCES, concentrationThresholdPercent: CONCENTRATION_THRESHOLD }, nextStep: 'Search at least 3 distinct relevant discovery sources, record searches or blockers with round source --stdin, and review round status before applying.' };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function discoveryGroup(id) {
|
|
1125
|
+
// Two views of the same hiring network do not demonstrate independent coverage.
|
|
1126
|
+
if (['yc-work-at-a-startup', 'yc-company-directory'].includes(id)) return 'yc';
|
|
1127
|
+
return id;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function discoverySummary(events, applications, completion) {
|
|
1131
|
+
const latest = new Map();
|
|
1132
|
+
const searchedIds = new Set();
|
|
1133
|
+
const attribution = new Map();
|
|
1134
|
+
for (const event of events.filter((item) => item.type === 'source-checked')) {
|
|
1135
|
+
latest.set(event.sourceId, event);
|
|
1136
|
+
if (event.status === 'searched') searchedIds.add(event.sourceId);
|
|
1137
|
+
for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
|
|
1138
|
+
}
|
|
1139
|
+
const sources = [...latest.values()];
|
|
1140
|
+
const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
|
|
1141
|
+
const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
|
|
1142
|
+
const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
|
|
1143
|
+
const distribution = new Map();
|
|
1144
|
+
let unattributedCount = 0;
|
|
1145
|
+
for (const entry of applications) {
|
|
1146
|
+
const source = entry.discoverySourceId ?? attribution.get(entry.id);
|
|
1147
|
+
if (!source || !searchedIds.has(source)) { unattributedCount++; continue; }
|
|
1148
|
+
const group = discoveryGroup(source);
|
|
1149
|
+
distribution.set(group, (distribution.get(group) ?? 0) + 1);
|
|
1150
|
+
}
|
|
1151
|
+
const maxCount = Math.max(0, ...distribution.values());
|
|
1152
|
+
const maxSourceSharePercent = applications.length ? Math.round(maxCount / applications.length * 100) : 0;
|
|
1153
|
+
return {
|
|
1154
|
+
minSources: MIN_DISCOVERY_SOURCES,
|
|
1155
|
+
attemptedSourceCount: attempted.size,
|
|
1156
|
+
searchedSourceCount: searched.size,
|
|
1157
|
+
blockedSourceCount: [...attempted].filter((id) => !searched.has(id)).length,
|
|
1158
|
+
coverageSatisfied: attempted.size >= MIN_DISCOVERY_SOURCES && searched.size > 0,
|
|
1159
|
+
sources: sources.map(({ sourceId, status, reviewedCount, qualifiedCount, blocker, evidence }) => ({ sourceId, status, searchedDuringRound: searchedIds.has(sourceId), reviewedCount, qualifiedCount, ...(blocker ? { blocker } : {}), evidence })),
|
|
1160
|
+
submissionDistribution: [...distribution].map(([sourceId, count]) => ({ sourceId, count })),
|
|
1161
|
+
unattributedCount,
|
|
1162
|
+
maxSourceSharePercent,
|
|
1163
|
+
concentrationNeedsExplanation: applications.length > 0 && maxCount / applications.length * 100 > CONCENTRATION_THRESHOLD,
|
|
1164
|
+
...(completion?.concentrationReason ? { concentrationReason: completion.concentrationReason, concentrationEvidence: completion.concentrationEvidence } : {}),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function roundSource(input) {
|
|
1169
|
+
const value = object(input, 'source coverage');
|
|
1170
|
+
const allowed = new Set(['roundId', 'sourceId', 'status', 'reviewedCount', 'qualifiedCount', 'blocker', 'evidence', 'applicationIds']);
|
|
1171
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown source coverage property: ${key}.`);
|
|
1172
|
+
const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
|
|
1173
|
+
const status = string(value.status, 'coverage.status', 20);
|
|
1174
|
+
if (!['searched', 'blocked'].includes(status)) throw new Error('coverage.status must be searched or blocked.');
|
|
1175
|
+
const reviewedCount = integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
|
|
1176
|
+
const qualifiedCount = integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
|
|
1177
|
+
const blocker = value.blocker == null ? null : string(value.blocker, 'coverage.blocker', 40);
|
|
1178
|
+
if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
|
|
1179
|
+
if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
|
|
1180
|
+
const evidence = string(value.evidence, 'coverage.evidence', 2000);
|
|
1181
|
+
const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
|
|
1182
|
+
if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
|
|
1183
|
+
const event = await withStateLock('rounds', async (dir) => {
|
|
1184
|
+
const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
|
|
1185
|
+
if (round.completed) throw new Error('Cannot record coverage for a completed round.');
|
|
1186
|
+
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1187
|
+
for (const id of applicationIds) {
|
|
1188
|
+
const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
|
|
1189
|
+
if (!entry || (entry.discoverySourceId && entry.discoverySourceId !== sourceId)) throw new Error('Coverage attribution must match a confirmed application in this round and its saved source.');
|
|
1190
|
+
}
|
|
1191
|
+
const event = { type: 'source-checked', roundId: round.roundId, sourceId, status, reviewedCount, qualifiedCount, evidence, applicationIds, ...(blocker ? { blocker } : {}), occurredAt: new Date().toISOString() };
|
|
1192
|
+
const file = join(dir, 'rounds.ndjson');
|
|
1193
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1194
|
+
await chmod(file, 0o600);
|
|
1195
|
+
return event;
|
|
1196
|
+
});
|
|
1197
|
+
await appendCloudEvent('rounds', event);
|
|
1198
|
+
return event;
|
|
1069
1199
|
}
|
|
1070
1200
|
|
|
1071
1201
|
function replayAttention(events, roundId = null) {
|
|
@@ -1135,7 +1265,8 @@ async function roundStatus(roundId = null) {
|
|
|
1135
1265
|
if (!id) throw new Error('No application round has been started.');
|
|
1136
1266
|
const started = starts.find((event) => event.roundId === id);
|
|
1137
1267
|
if (!started) throw new Error('Application round was not found.');
|
|
1138
|
-
const
|
|
1268
|
+
const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
|
|
1269
|
+
const applications = [...new Map(matching.map((entry, index) => [canonicalApplicationKey(entry, String(index)), entry])).values()];
|
|
1139
1270
|
const confirmedCount = new Set(applications.map((entry, index) => canonicalApplicationKey(entry, String(index)))).size;
|
|
1140
1271
|
const attention = await attentionList(id);
|
|
1141
1272
|
const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
|
|
@@ -1146,6 +1277,7 @@ async function roundStatus(roundId = null) {
|
|
|
1146
1277
|
remainingCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
1147
1278
|
blockedCount: attention.count,
|
|
1148
1279
|
completed: Boolean(completion),
|
|
1280
|
+
discovery: discoverySummary(events.filter((event) => event.roundId === id), applications, completion),
|
|
1149
1281
|
startedAt: started.occurredAt,
|
|
1150
1282
|
...(completion ? { completedAt: completion.occurredAt } : {}),
|
|
1151
1283
|
};
|
|
@@ -1153,14 +1285,28 @@ async function roundStatus(roundId = null) {
|
|
|
1153
1285
|
|
|
1154
1286
|
async function roundComplete(input) {
|
|
1155
1287
|
const value = object(input, 'round completion');
|
|
1156
|
-
const allowed = new Set(['roundId', 'completedAt']);
|
|
1288
|
+
const allowed = new Set(['roundId', 'completedAt', 'concentrationReason', 'concentrationEvidence']);
|
|
1157
1289
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round completion property: ${key}.`);
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1290
|
+
// Use the same lock order as ledger writes so completion cannot race a confirmed submission.
|
|
1291
|
+
const result = await withStateLock('applications', () => withStateLock('rounds', async (dir) => {
|
|
1292
|
+
const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
|
|
1293
|
+
if (status.completed) return { ...status, completionRecorded: false };
|
|
1294
|
+
if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
|
|
1295
|
+
if (!status.discovery.coverageSatisfied) throw new Error('Round requires attempts across at least 3 distinct discovery sources, including at least one searched source. Record coverage and blockers with round source --stdin.');
|
|
1296
|
+
if (status.discovery.unattributedCount) throw new Error('Round requires discovery source attribution for every confirmed submission. Supply missing attribution using round source applicationIds; do not rewrite the ledger.');
|
|
1297
|
+
let explanation = {};
|
|
1298
|
+
if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
|
|
1299
|
+
if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
|
|
1300
|
+
explanation = { concentrationReason: value.concentrationReason, concentrationEvidence: string(value.concentrationEvidence, 'round.concentrationEvidence', 2000) };
|
|
1301
|
+
}
|
|
1302
|
+
const event = { type: 'completed', roundId: status.roundId, occurredAt: isoDate(value.completedAt, 'round.completedAt'), ...explanation };
|
|
1303
|
+
const file = join(dir, 'rounds.ndjson');
|
|
1304
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1305
|
+
await chmod(file, 0o600);
|
|
1306
|
+
return { ...status, discovery: { ...status.discovery, ...explanation }, completed: true, completionRecorded: true, completedAt: event.occurredAt };
|
|
1307
|
+
}));
|
|
1308
|
+
if (result.completionRecorded) await appendCloudEvent('rounds', { type: 'completed', roundId: result.roundId, occurredAt: result.completedAt, ...(result.discovery.concentrationReason ? { concentrationReason: result.discovery.concentrationReason, concentrationEvidence: result.discovery.concentrationEvidence } : {}) });
|
|
1309
|
+
return result;
|
|
1164
1310
|
}
|
|
1165
1311
|
|
|
1166
1312
|
async function frictionRecord(input) {
|
|
@@ -1237,6 +1383,14 @@ async function ledgerOutcome(outcomeInput) {
|
|
|
1237
1383
|
return { stored: true, enriched };
|
|
1238
1384
|
});
|
|
1239
1385
|
const applications = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
|
|
1386
|
+
if (storage.stored && await cloudState.configured()) {
|
|
1387
|
+
await cloudState.appendRecord('outcomes', event, {
|
|
1388
|
+
recordKey: event.id,
|
|
1389
|
+
idempotencyKey: `outcome:${event.id}:${event.status}:${event.occurredAt}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`,
|
|
1390
|
+
occurredAt: event.occurredAt,
|
|
1391
|
+
queueOnFailure: true,
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1240
1394
|
return { result: { recorded: storage.stored, duplicate: !storage.stored, enriched: storage.enriched, recordedOutcome: event.id, status }, application: applications.find((entry) => entry.id === event.id) ?? null, event };
|
|
1241
1395
|
}
|
|
1242
1396
|
|
|
@@ -1259,9 +1413,62 @@ async function ledgerReviewAcknowledge(input) {
|
|
|
1259
1413
|
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1260
1414
|
await chmod(file, 0o600);
|
|
1261
1415
|
});
|
|
1416
|
+
if (await cloudState.configured()) await cloudState.appendRecord('reviews', event, { recordKey: reviewedAt, idempotencyKey: `review:${reviewedAt}`, occurredAt: reviewedAt, queueOnFailure: true });
|
|
1262
1417
|
return { acknowledged: true, ...event };
|
|
1263
1418
|
}
|
|
1264
1419
|
|
|
1420
|
+
async function prepareCloudState(area, action) {
|
|
1421
|
+
if (!await cloudState.configured() || area === 'cloud') return;
|
|
1422
|
+
try {
|
|
1423
|
+
await cloudState.reconcile({ dryRun: false, provenance: 'automatic-recovery' });
|
|
1424
|
+
await cloudState.refreshDocumentCaches();
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
// New submissions still fail closed because lease and intent operations
|
|
1427
|
+
// call the cloud directly. Local checks, research, and observed-result
|
|
1428
|
+
// ledger appends can continue and queue their idempotent recovery writes.
|
|
1429
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async function cloudCommand(action, value) {
|
|
1434
|
+
if (action === 'configure' && value === '--stdin') {
|
|
1435
|
+
const configured = await saveCloudConfig(await jsonStdin(), { configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
1436
|
+
const updateGuard = await enableCloudUpdateGuard();
|
|
1437
|
+
return { configured: true, url: configured.url, clientId: configured.clientId ?? null, clientName: configured.clientName ?? null, token: configured.token, updateGuard };
|
|
1438
|
+
}
|
|
1439
|
+
if (action === 'status' && value == null) return cloudState.status();
|
|
1440
|
+
if (action === 'reconcile' && value === '--dry-run') return cloudState.reconcile({ dryRun: true });
|
|
1441
|
+
if (action === 'reconcile' && value == null) return cloudState.reconcile({ dryRun: false });
|
|
1442
|
+
if (action === 'export') return cloudState.exportTo(value ?? null);
|
|
1443
|
+
if (action === 'lease-acquire' && value == null) {
|
|
1444
|
+
const lease = await cloudState.acquireLease();
|
|
1445
|
+
await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), lease);
|
|
1446
|
+
return lease;
|
|
1447
|
+
}
|
|
1448
|
+
if (action === 'lease-renew' && value == null) {
|
|
1449
|
+
const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
|
|
1450
|
+
if (!lease?.leaseId) throw new Error('No local cloud lease is recorded. Run cloud lease-acquire.');
|
|
1451
|
+
const renewed = await cloudState.renewLease(lease.leaseId);
|
|
1452
|
+
await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), renewed);
|
|
1453
|
+
return renewed;
|
|
1454
|
+
}
|
|
1455
|
+
if (action === 'lease-release' && value == null) {
|
|
1456
|
+
const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
|
|
1457
|
+
if (!lease?.leaseId) throw new Error('No local cloud lease is recorded.');
|
|
1458
|
+
return cloudState.releaseLease(lease.leaseId);
|
|
1459
|
+
}
|
|
1460
|
+
if (action === 'intent-prepare' && value === '--stdin') return cloudState.createIntent(await jsonStdin());
|
|
1461
|
+
if (action === 'intent-sent' && value === '--stdin') {
|
|
1462
|
+
const input = await jsonStdin();
|
|
1463
|
+
return cloudState.markIntentSentUnverified(string(input.intentId, 'intentId', 200), string(input.leaseId, 'leaseId', 200));
|
|
1464
|
+
}
|
|
1465
|
+
if (action === 'intent-confirm' && value === '--stdin') {
|
|
1466
|
+
const input = await jsonStdin();
|
|
1467
|
+
return cloudState.confirmIntent(string(input.intentId, 'intentId', 200), object(input.application, 'application'), string(input.leaseId, 'leaseId', 200), input.idempotencyKey);
|
|
1468
|
+
}
|
|
1469
|
+
throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1265
1472
|
function print(value) {
|
|
1266
1473
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1267
1474
|
}
|
|
@@ -1309,6 +1516,11 @@ function roundCompletedTelemetry(round) {
|
|
|
1309
1516
|
pausedCount: round.blockedCount,
|
|
1310
1517
|
errorCount: 0,
|
|
1311
1518
|
durationBucket: durationBucket(Math.max(0, Date.parse(round.completedAt) - Date.parse(round.startedAt))),
|
|
1519
|
+
attemptedSourceCount: round.discovery.attemptedSourceCount,
|
|
1520
|
+
searchedSourceCount: round.discovery.searchedSourceCount,
|
|
1521
|
+
blockedSourceCount: round.discovery.blockedSourceCount,
|
|
1522
|
+
maxSourceSharePercent: round.discovery.maxSourceSharePercent,
|
|
1523
|
+
...(round.discovery.concentrationReason ? { concentrationReason: round.discovery.concentrationReason } : {}),
|
|
1312
1524
|
},
|
|
1313
1525
|
};
|
|
1314
1526
|
}
|
|
@@ -1316,7 +1528,8 @@ function roundCompletedTelemetry(round) {
|
|
|
1316
1528
|
async function executeCommand([area, action, value], telemetry, session, community) {
|
|
1317
1529
|
const domainEvents = [];
|
|
1318
1530
|
let result;
|
|
1319
|
-
if (area === '
|
|
1531
|
+
if (area === 'cloud') result = await cloudCommand(action, value);
|
|
1532
|
+
else if (area === 'profile' && action === 'set' && value === '--stdin') {
|
|
1320
1533
|
const profile = await jsonStdin();
|
|
1321
1534
|
result = await profileSet(profile);
|
|
1322
1535
|
} else if (area === 'profile' && action === 'migrate' && value === '--stdin') {
|
|
@@ -1338,7 +1551,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1338
1551
|
const input = await jsonStdin();
|
|
1339
1552
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
1340
1553
|
const entry = validateLedgerEntry(input);
|
|
1341
|
-
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
|
|
1554
|
+
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride, input.cloudIntentId && input.cloudLeaseId ? { intentId: input.cloudIntentId, leaseId: input.cloudLeaseId } : null);
|
|
1342
1555
|
result.communityJob = await communityJobsSync(community, { limit: 1, applicationIds: [entry.id] });
|
|
1343
1556
|
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
1344
1557
|
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
@@ -1356,10 +1569,18 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1356
1569
|
else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
|
|
1357
1570
|
else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
|
|
1358
1571
|
else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
|
|
1572
|
+
else if (area === 'round' && action === 'source' && value === '--stdin') {
|
|
1573
|
+
result = await roundSource(await jsonStdin());
|
|
1574
|
+
domainEvents.push({ event: 'source_checked', properties: {
|
|
1575
|
+
sourceId: result.sourceId.startsWith('community-') ? 'community' : result.sourceId,
|
|
1576
|
+
status: result.status, reviewedCount: result.reviewedCount, qualifiedCount: result.qualifiedCount,
|
|
1577
|
+
...(result.blocker ? { blocker: result.blocker } : {}),
|
|
1578
|
+
} });
|
|
1579
|
+
}
|
|
1359
1580
|
else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
|
|
1360
1581
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1361
1582
|
result = await roundComplete(await jsonStdin());
|
|
1362
|
-
domainEvents.push(roundCompletedTelemetry(result));
|
|
1583
|
+
if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
|
|
1363
1584
|
} else if (area === 'sources' && action === 'list' && value == null) {
|
|
1364
1585
|
await syncAllCommunityData(community);
|
|
1365
1586
|
result = await sourcesList({}, await community.list());
|
|
@@ -1382,7 +1603,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1382
1603
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1383
1604
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1384
1605
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1385
|
-
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1606
|
+
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|source|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1386
1607
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1387
1608
|
return result;
|
|
1388
1609
|
}
|
|
@@ -1403,9 +1624,14 @@ async function recordInstallationStart(telemetry, session) {
|
|
|
1403
1624
|
|
|
1404
1625
|
async function main(args) {
|
|
1405
1626
|
const [area, action, value] = args;
|
|
1406
|
-
const telemetry = new TelemetryClient({ stateDir: stateDir()
|
|
1627
|
+
const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
|
|
1628
|
+
const profile = storedProfileRaw();
|
|
1629
|
+
// Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.
|
|
1630
|
+
return Object.fromEntries(['name', 'email'].filter((key) => typeof profile[key] === 'string' && profile[key].trim()).map((key) => [key, profile[key]]));
|
|
1631
|
+
} });
|
|
1407
1632
|
const community = new SourceCommunityClient({ stateDir: stateDir() });
|
|
1408
1633
|
if (area === 'telemetry') {
|
|
1634
|
+
if (action === 'identity' && ['status', 'enable', 'disable'].includes(value) && args.length === 3) return print(await telemetry.configureIdentity(value));
|
|
1409
1635
|
if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
|
|
1410
1636
|
if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
|
|
1411
1637
|
if (action === 'record' && value === '--stdin') {
|
|
@@ -1413,9 +1639,11 @@ async function main(args) {
|
|
|
1413
1639
|
await recordInstallationStart(telemetry, session);
|
|
1414
1640
|
return print(await telemetry.record(await jsonStdin(), session, { strict: true }));
|
|
1415
1641
|
}
|
|
1416
|
-
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1642
|
+
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin; telemetry identity status|enable|disable');
|
|
1417
1643
|
}
|
|
1418
1644
|
|
|
1645
|
+
await prepareCloudState(area, action);
|
|
1646
|
+
|
|
1419
1647
|
const command = commandCategory(args);
|
|
1420
1648
|
const session = await telemetry.beginCommand(command);
|
|
1421
1649
|
await recordInstallationStart(telemetry, session);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
|
|
4
|
+
import { createTelemetryEnvelope, jobIdentity, validateEvent, validateTelemetryIdentity } from './telemetry-schema.mjs';
|
|
5
5
|
import { SKILL_VERSION } from './version.mjs';
|
|
6
6
|
|
|
7
7
|
export { SKILL_VERSION };
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
10
|
-
export const TELEMETRY_NOTICE = '
|
|
10
|
+
export const TELEMETRY_NOTICE = 'Usage analytics are enabled by default. They include structured job and workflow metrics. Name and email sharing has a separate disclosure and opt-out. Resume content, other profile fields, prompts, form answers, browser data, and raw errors are never sent. Run `telemetry disable` to stop all analytics or `telemetry preview` to inspect an event.\n';
|
|
11
|
+
export const IDENTITY_NOTICE = 'Name and email sharing is enabled by default. Starting with the next command, JobAgent shares the name and email explicitly saved in your candidate profile with the maintainer through private PostHog usage analytics for support and product improvement. Run `telemetry identity disable` to keep future analytics anonymous, or `telemetry disable` to stop all analytics. Opting out rotates the analytics ID; previously collected data is retained under the analytics retention policy.\n';
|
|
11
12
|
|
|
12
13
|
const CONFIG_VERSION = 1;
|
|
13
14
|
const CONFIG_FILE = 'telemetry.json';
|
|
@@ -38,13 +39,14 @@ async function writePrivate(file, value) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export class TelemetryClient {
|
|
41
|
-
constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000) }) {
|
|
42
|
+
constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000), readIdentity = () => undefined }) {
|
|
42
43
|
this.stateDir = stateDir;
|
|
43
44
|
this.endpoint = endpoint.replace(/\/$/, '');
|
|
44
45
|
this.fetch = fetchFn;
|
|
45
46
|
this.stderr = stderr;
|
|
46
47
|
this.now = now;
|
|
47
48
|
this.timeoutMs = timeoutMs;
|
|
49
|
+
this.readIdentity = readIdentity;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
@@ -57,7 +59,7 @@ export class TelemetryClient {
|
|
|
57
59
|
async readConfig() {
|
|
58
60
|
try {
|
|
59
61
|
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
60
|
-
return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
62
|
+
return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null, identityEnabled: value.identityEnabled !== false, identityDisclosed: value.identityDisclosed === true };
|
|
61
63
|
} catch (error) {
|
|
62
64
|
if (error.code === 'ENOENT') return null;
|
|
63
65
|
return { version: CONFIG_VERSION, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
@@ -101,7 +103,13 @@ export class TelemetryClient {
|
|
|
101
103
|
config.graceConsumed = true;
|
|
102
104
|
await this.saveConfig(config);
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
const allowIdentity = config.identityEnabled !== false && config.identityDisclosed === true;
|
|
107
|
+
if (config.enabled && config.identityEnabled !== false && !config.identityDisclosed) {
|
|
108
|
+
this.stderr(IDENTITY_NOTICE);
|
|
109
|
+
config.identityDisclosed = true;
|
|
110
|
+
await this.saveConfig(config);
|
|
111
|
+
}
|
|
112
|
+
return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, allowIdentity, installationEventPending: config.installationEventPending === true };
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async credentials(config) {
|
|
@@ -128,13 +136,17 @@ export class TelemetryClient {
|
|
|
128
136
|
if (session.unavailable) return { sent: false, reason: 'unavailable' };
|
|
129
137
|
let config = await this.readConfig();
|
|
130
138
|
if (!config?.enabled) return { sent: false, reason: 'disabled' };
|
|
139
|
+
let identity;
|
|
140
|
+
if (session.allowIdentity === true && config.identityEnabled !== false && config.identityDisclosed === true) {
|
|
141
|
+
try { identity = validateTelemetryIdentity(await this.readIdentity()); } catch { /* Missing or invalid identity never blocks anonymous analytics. */ }
|
|
142
|
+
}
|
|
131
143
|
config = await this.credentials(config);
|
|
132
|
-
const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION });
|
|
144
|
+
const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity });
|
|
133
145
|
let response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
134
146
|
if (response.status === 401) {
|
|
135
147
|
config.tokenExpiresAt = null;
|
|
136
148
|
config = await this.credentials(config);
|
|
137
|
-
response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION })), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
149
|
+
response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity })), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
138
150
|
}
|
|
139
151
|
if (!response.ok) {
|
|
140
152
|
session.unavailable = true;
|
|
@@ -154,7 +166,22 @@ export class TelemetryClient {
|
|
|
154
166
|
|
|
155
167
|
async status() {
|
|
156
168
|
const config = await this.readConfig();
|
|
157
|
-
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
169
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), installationId: config?.installationId ?? null, identityEnabled: config?.identityEnabled ?? true, identityDisclosed: config?.identityDisclosed ?? false, endpoint: this.endpoint, schemaVersion: 1 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async configureIdentity(action) {
|
|
173
|
+
if (action === 'status') return this.status();
|
|
174
|
+
if (!['enable', 'disable'].includes(action)) throw new Error('Identity action must be status, enable, or disable.');
|
|
175
|
+
const current = await this.readConfig() ?? { version: CONFIG_VERSION, enabled: true, disclosed: false, graceConsumed: true, installationEventPending: true };
|
|
176
|
+
const enabled = action === 'enable';
|
|
177
|
+
// Rotate at both boundaries to avoid identifying an earlier anonymous period.
|
|
178
|
+
if (enabled !== (current.identityEnabled !== false)) {
|
|
179
|
+
Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
|
|
180
|
+
}
|
|
181
|
+
if (enabled && current.identityEnabled === false) current.identityDisclosed = false;
|
|
182
|
+
current.identityEnabled = enabled;
|
|
183
|
+
await this.saveConfig(current);
|
|
184
|
+
return this.status();
|
|
158
185
|
}
|
|
159
186
|
|
|
160
187
|
async configure(action) {
|
|
@@ -31,6 +31,9 @@ const FAILURE_POINTS = values('role-scope', 'company-problem', 'constraints', 'i
|
|
|
31
31
|
const ERROR_CODES = values('invalid_input', 'network_failure', 'relay_unavailable', 'authentication_required', 'site_changed', 'upload_failed', 'submission_unconfirmed', 'rate_limited', 'internal_error');
|
|
32
32
|
const MATCH_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'remote', 'salary', 'ai', 'product', 'leadership', 'authorization');
|
|
33
33
|
const GAP_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'salary_unknown', 'salary_below', 'authorization_unclear', 'sponsorship', 'experience', 'domain', 'other');
|
|
34
|
+
const DISCOVERY_SOURCE_KEYS = values('direct-company-careers', 'linkedin-jobs-feed', 'x-hiring-feed', 'yc-work-at-a-startup', 'yc-company-directory', 'hacker-news-who-is-hiring', 'we-work-remotely', 'a16z-build-jobs', 'engg-space', 'js-guru-jobs', 'linux-careers', 'indeed', 'recruiter-inbound', 'user-supplied-leads', 'community');
|
|
35
|
+
const DISCOVERY_BLOCKERS = values('login', 'mfa', 'captcha', 'site-error', 'access-unavailable');
|
|
36
|
+
const CONCENTRATION_REASONS = values('stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed');
|
|
34
37
|
|
|
35
38
|
const text = (max, identitySafe = false) => ({ kind: 'text', max, identitySafe });
|
|
36
39
|
const integer = (min, max) => ({ kind: 'integer', min, max });
|
|
@@ -43,6 +46,7 @@ const JOB = {
|
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
export const EVENT_SCHEMAS = {
|
|
49
|
+
source_checked: { required: { sourceId: enumValue(DISCOVERY_SOURCE_KEYS), status: enumValue(values('searched', 'blocked')), reviewedCount: integer(0, 10000), qualifiedCount: integer(0, 10000) }, optional: { blocker: enumValue(DISCOVERY_BLOCKERS) } },
|
|
46
50
|
installation_started: { required: { osFamily: enumValue(values('macos', 'linux', 'windows', 'other')), nodeMajor: integer(20, 99), submissionMode: enumValue(SUBMISSION_MODES) } },
|
|
47
51
|
command_completed: { required: { command: enumValue(COMMANDS), result: enumValue(RESULTS), durationBucket: enumValue(DURATIONS) } },
|
|
48
52
|
job_discovered: { required: { ...JOB, source: enumValue(SOURCES), jobCountry: text(80, true), workMode: enumValue(WORK_MODES), seniority: enumValue(SENIORITIES), employmentType: enumValue(EMPLOYMENT), roleFamily: enumValue(ROLE_FAMILIES) }, optional: { salaryCurrency: { kind: 'currency' }, salaryMin: integer(0, 10000000), salaryMax: integer(0, 10000000) } },
|
|
@@ -52,7 +56,7 @@ export const EVENT_SCHEMAS = {
|
|
|
52
56
|
application_paused: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), reason: enumValue(PAUSE_REASONS) } },
|
|
53
57
|
application_skipped: { required: { jobHash: { kind: 'hash' }, reason: enumValue(SKIP_REASONS), fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY) } },
|
|
54
58
|
application_submitted: { required: { ...JOB, durationBucket: enumValue(DURATIONS), fieldsFilled: integer(0, 500), shortAnswerCount: integer(0, 100), resumeUploaded: boolean, approvalMode: enumValue(APPROVAL_MODES) } },
|
|
55
|
-
round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) } },
|
|
59
|
+
round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) }, optional: { attemptedSourceCount: integer(0, 10000), searchedSourceCount: integer(0, 10000), blockedSourceCount: integer(0, 10000), maxSourceSharePercent: integer(0, 100), concentrationReason: enumValue(CONCENTRATION_REASONS) } },
|
|
56
60
|
outcome_recorded: {
|
|
57
61
|
required: { ...JOB, outcome: enumValue(OUTCOMES), daysSinceSubmission: integer(0, 3650) },
|
|
58
62
|
optional: { interviewQuality: enumValue(INTERVIEW_QUALITIES), failurePoint: enumValue(FAILURE_POINTS) },
|
|
@@ -118,6 +122,11 @@ export function validateEvent(input) {
|
|
|
118
122
|
}
|
|
119
123
|
for (const [name, rule] of Object.entries(schema.optional ?? {})) if (name in input.properties) properties[name] = validateProperty(name, input.properties[name], rule);
|
|
120
124
|
if (input.event === 'outcome_recorded' && properties.failurePoint && !properties.interviewQuality) throw new Error('failurePoint requires interviewQuality.');
|
|
125
|
+
if (input.event === 'source_checked') {
|
|
126
|
+
if (properties.qualifiedCount > properties.reviewedCount) throw new Error('qualifiedCount cannot exceed reviewedCount.');
|
|
127
|
+
if (properties.status === 'blocked' && (!properties.blocker || properties.reviewedCount !== 0 || properties.qualifiedCount !== 0)) throw new Error('Blocked sources require a blocker and zero counts.');
|
|
128
|
+
if (properties.status === 'searched' && properties.blocker) throw new Error('Searched sources cannot have a blocker.');
|
|
129
|
+
}
|
|
121
130
|
const result = { event: input.event, properties };
|
|
122
131
|
if (new TextEncoder().encode(JSON.stringify(result)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry event exceeds 4 KB.');
|
|
123
132
|
return result;
|
|
@@ -142,19 +151,37 @@ export async function jobIdentity(value) {
|
|
|
142
151
|
return { jobHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''), domain: new URL(canonical).hostname };
|
|
143
152
|
}
|
|
144
153
|
|
|
145
|
-
|
|
154
|
+
// Identity is a separate allowlisted envelope field, never arbitrary event input.
|
|
155
|
+
export function validateTelemetryIdentity(input) {
|
|
156
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry identity must be an object.');
|
|
157
|
+
for (const key of Object.keys(input)) if (!['name', 'email'].includes(key)) throw new Error('Unknown telemetry identity property.');
|
|
158
|
+
const identity = {};
|
|
159
|
+
for (const [key, max] of [['name', 160], ['email', 254]]) {
|
|
160
|
+
if (!(key in input)) continue;
|
|
161
|
+
const value = input[key];
|
|
162
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) throw new Error('Invalid telemetry identity field.');
|
|
163
|
+
const normalized = value.trim();
|
|
164
|
+
if (key === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) throw new Error('Invalid telemetry identity email.');
|
|
165
|
+
identity[key] = normalized;
|
|
166
|
+
}
|
|
167
|
+
if (!Object.keys(identity).length) throw new Error('Telemetry identity must include name or email.');
|
|
168
|
+
return identity;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion, identity }) {
|
|
146
172
|
if (!UUID.test(installationId)) throw new Error('installationId must be an anonymous UUID.');
|
|
147
173
|
if (typeof token !== 'string' || token.length < 8 || token.length > 2048) throw new Error('token is invalid.');
|
|
148
174
|
if (typeof skillVersion !== 'string' || !VERSION.test(skillVersion)) throw new Error('skillVersion is invalid.');
|
|
149
175
|
const safe = validateEvent(typeof event === 'string' ? { event, properties } : event);
|
|
150
176
|
const envelope = { schemaVersion: TELEMETRY_SCHEMA_VERSION, skillVersion, installationId, token, ...safe };
|
|
177
|
+
if (identity !== undefined) envelope.identity = validateTelemetryIdentity(identity);
|
|
151
178
|
if (new TextEncoder().encode(JSON.stringify(envelope)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry payload exceeds 4 KB.');
|
|
152
179
|
return envelope;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
export function validateTelemetryEnvelope(input) {
|
|
156
183
|
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry payload must be an object.');
|
|
157
|
-
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties']);
|
|
184
|
+
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties', 'identity']);
|
|
158
185
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown telemetry envelope property: ${key}.`);
|
|
159
186
|
if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) throw new Error('Unsupported telemetry schema version.');
|
|
160
187
|
return createTelemetryEnvelope(input);
|